Your CI is pinned to actions/checkout@v5 and node@22. It is not pinned to the
compiler. GitHub rebuilds the hosted runner images roughly weekly and you cannot
select an older one — the feature request was rejected
("there's no technical feasibility for implementation yet"), and GitHub staff have
said plainly that it's impossible to specify an older runner image in a workflow.
So when Clang, Python or CMake moves underneath you, the first sign is a red build
with no diff to blame.
runner-drift locks the tool versions your workflows actually use, diffs them on
every image bump, and names the runner-images commit that shipped the change.
It also answers the question every ubuntu-22.04 user has right now — GitHub is
deprecating that image from 2026-09-17, fully unsupported 2027-04-17, with four
brownouts starting 2027-03-23:
what actually breaks if I move to ubuntu-24.04?
$ npx runner-drift plan --from ubuntu-22.04 --to ubuntu-24.04
ubuntu-22.04 -> ubuntu-24.04 (images 20260907.292.1 -> 20260907.300.1)
ubuntu-22.04 is fully unsupported on 2027-04-17; brownouts begin 2027-03-23 (source: actions/runner-images#14254)
209 days left (184 days until the first brownout) — deprecation began 2026-09-17; see https://github.com/actions/runner-images/issues/14254
brownout windows (14:00-00:00 UTC): 2027-03-23, 2027-03-30, 2027-04-06, 2027-04-13
announced migration targets: ubuntu-24.04, ubuntu-26.04, ubuntu-latest
OS 22.04.5 LTS -> 24.04.5 LTS MAJOR
Kernel 6.8.0-1064-azure -> 6.17.0-1022-azure MINOR
Systemd 249.11-0ubuntu3.22 -> 255.4-1ubuntu8.17 MAJOR
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)
That is real output against the live manifests, run on 2026-09-20. Note what is not there: CMake. It is 3.31.6 on both images, so it is suppressed — the report is only the rows that affect you, picked by scanning your own workflows for the tools your steps invoke.
Since 1.2.0 it answers the same question about the other half of the fleet.
GitHub enforces a minimum self-hosted runner agent version, and the rule is
rolling: you have 30 days from each actions/runner release to install it, or
the Actions service stops queueing jobs to your runner.
runner-drift runners reads the dates straight from GitHub's API and names the
runners that are about to go quiet. See
Self-hosted agent versions.
Since 1.3.0 it watches a third clock, and this one is nearly out. GitHub
removes Node 20 from the hosted runner images on 2026-09-23,
and any action declaring runs.using: node20 stops working that day. Your
workflow does not say which runtime it is asking for: uses: actions/checkout@v4
looks like a pin, and it is node20. So is actions/upload-artifact@v4.
runner-drift actions resolves every uses: to the runtime the action really
declares, follows composites and reusable workflows into whatever they call, and
names the step that breaks rather than the line you wrote. See
which uses: survive.
Since 1.4.0 it also covers the case where the label does not change but the image
does. GitHub is moving ubuntu-latest from Ubuntu 24.04 to Ubuntu 26.04, rolling
out between 2026-10-19 and 2026-11-19.
For that month ubuntu-latest is two different operating systems depending on
which runner your job lands on, and nothing in your workflow file changes. plan
resolves the floating label to the two concrete images GitHub named and diffs
them, so you get the kernel and systemd deltas before the rollout reaches you.
See when a floating label moves.
- No account, no hosted service, no paid tier. Two endpoints only:
raw.githubusercontent.comandapi.github.com. - The image and action lanes run unauthenticated;
GITHUB_TOKENonly raises the rate limit (and reaches actions in private repos). Therunnerslane is the exception: GitHub never serves the self-hosted runner endpoints anonymously, so it needs a token with administration read. - Zero runtime dependencies. Node 22+, ESM.
npx runner-drift --help # no install
npm i -D runner-drift # or as a dev dependency
npm i -g runner-drift # or globally$ runner-drift init
Scanned 2 workflow file(s) in .github/workflows
Runner label: ubuntu-22.04 (image 20260907.292.1, 22.04.5 LTS)
Locked 3 tool(s): CMake, Clang, Python
Wrote runner-lock.json
Heads up: ubuntu-22.04 is fully unsupported on 2027-04-17 (https://github.com/actions/runner-images/issues/14254)
Preview the move: runner-drift plan --from ubuntu-22.04 --to ubuntu-24.04
Next: add the guard step to your workflow (see the README) and commit runner-lock.json.Commit runner-lock.json.
Add the action to any job (it lives at the root of this repo, so it also works straight from the Marketplace):
- uses: Booyaka101/runner-drift@v1
with:
fail-on: major # omit to report only and never fail the jobOr call the CLI directly:
- run: npx runner-drift guard --fail-on major
env:
GITHUB_TOKEN: ${{ github.token }}The first run records the baseline and exits 0:
baseline recorded — ubuntu-22.04 image 20260623.199.1
Terraform: 1.15.6 (from manifest)
Kotlin: 2.4.0-release-281 (from manifest)
CMake: 3.31.6 (from manifest)
Wrote runner-lock.json. Commit it so the next image bump can be diffed.
A later run, after GitHub has rolled four new images:
::warning title=runner-drift: Terraform patch::Terraform drifted on ubuntu-22.04: 1.15.6 -> 1.15.8 (PATCH) — shipped by 20260714.228.1 https://github.com/actions/runner-images/commit/f3d0fbf668c2d437a5a5a03e75206801e22e5e62
::warning title=runner-drift: Kotlin patch::Kotlin drifted on ubuntu-22.04: 2.4.0-release-281 -> 2.4.10-release-377 (PATCH) — shipped by 20260720.234.2 https://github.com/actions/runner-images/commit/3b7fa9c1aa1efb5fc0ba4b443dcfa69f47f53434
ubuntu-22.04 image 20260623.199.1 -> 20260720.234.2
Terraform 1.15.6 -> 1.15.8 PATCH [20260714.228.1] https://github.com/actions/runner-images/commit/f3d0fbf668c2d437a5a5a03e75206801e22e5e62
Kotlin 2.4.0-release-281 -> 2.4.10-release-377 PATCH [20260720.234.2] https://github.com/actions/runner-images/commit/3b7fa9c1aa1efb5fc0ba4b443dcfa69f47f53434
…and the same thing as a table in the job summary:
ubuntu-22.04 image 20260623.199.1 → 20260720.234.2
| 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 the run, and each tool is pinned
to the specific one that changed it — not just "the newest image". CMake did
not move, so it is not in the table.
runner-drift plan --from ubuntu-22.04 --to ubuntu-24.04
runner-drift plan --from macos-14 --to macos-15 --tools python,node,dotnet
runner-drift plan --from ubuntu-22.04 --to ubuntu-26.04 --json
runner-drift plan --from ubuntu-latest # resolved from the MIGRATIONS table--to is required except for a floating label with an announced migration, where
GitHub has already named both ends. See
when a floating label moves.
Deprecated images get scheduled brownouts before removal: macos-14 jobs fail
14:00-00:00 UTC on eight dates starting 2026-10-05, then the label disappears on
2026-11-02 (#13518);
ubuntu-22.04 follows the same script from 2027-03-23
(#14254). The first
brownout looks exactly like flaky CI, and by then the fix is urgent.
guard --fail-on-retirement <days> scans your workflow files for pinned
runs-on: labels and fails while the migration is still routine. It needs no
lock file and no hosted runner, so it works as a plain lint job:
runner-retirement:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: Booyaka101/runner-drift@v1
with:
fail-on-retirement: 60 # or: npx runner-drift guard --fail-on-retirement 60Each hit is a file annotation on the exact runs-on: line, with the dates, the
announced migration targets and the source issue:
::error file=.github/workflows/release.yml,line=12,col=14,title=runner-drift: macos-14 retires in 82 days::macos-14 is fully unsupported on 2026-11-02 (82 days); next brownout 2026-10-05 (54 days). Migrate to macos-15, macos-26, macos-latest. See https://github.com/actions/runner-images/issues/13518
runner-drift: macos-14 is fully unsupported on 2026-11-02 (82 days) and --fail-on-retirement 60 is set.
When only a brownout falls inside the threshold the annotation is a ::warning
(runner-drift: <label> deprecation), but the job still fails: those are the
dates your builds break. The step summary gets a table (Label, Where, Next
brownout, Fully unsupported, Migrate to, Source), --json gets a retirement
block, and a label already past its date always fails, whatever the threshold.
ubuntu-latest and friends float past retirements, so this lane never flags
them; they get their own lane, below.
self-hosted is never flagged at all. runs-on: ${{ matrix.os }} is resolved from the
matrix values in the same file.
runs-on: ubuntu-latest is not a pin, and once a year that matters. GitHub
announced on 2026-09-17 that the label
migrates from Ubuntu 24.04 to Ubuntu 26.04 between 2026-10-19 and 2026-11-19.
During the rollout the label means whichever image your job happens to land on,
so a build can pass and fail on the same commit with nothing to diff.
src/labels.mjs carries the announced moves in a MIGRATIONS table, keyed by
floating label, holding the two concrete labels, the window and the source issue.
That is enough for plan to stop refusing the floating label and diff the two
real images instead:
$ npx runner-drift plan --from ubuntu-latest
ubuntu-latest moves from ubuntu-24.04 to ubuntu-26.04. The rollout starts 2026-10-19 (18 days) and finishes 2026-11-19 (49 days).
announced 2026-09-17; source actions/runner-images#14748 https://github.com/actions/runner-images/issues/14748
ubuntu-24.04 -> ubuntu-26.04 (images 20260907.300.1 -> 20260907.131.1)
ubuntu-24.04 has no announced deprecation deadline in runner-drift's table.
OS 24.04.5 LTS -> 26.04.1 LTS MAJOR
Kernel 6.17.0-1022-azure -> 7.0.0-1012-azure MAJOR
Systemd 255.4-1ubuntu8.17 -> 259.5-0ubuntu3.4 MAJOR
CMake 3.31.6 -> 4.4.3 MAJOR
Node.js 22.23.2 -> 24.20.0 MAJOR
Python 3.12.3 -> 3.14.4 MINOR
3 of 3 detected tool(s) change
(Real output against the live manifests, run with --as-of 2026-10-01 for the
countdown and the fixture workflow in test/fixtures/workflows-migration.)
In CI, guard --fail-on-migration <days> scans the runs-on: lines for floating
labels with an announced move and fails while the window is still ahead of you:
runner-migration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: Booyaka101/runner-drift@v1
with:
fail-on-migration: 30 # or: npx runner-drift guard --fail-on-migration 30It is opt-in, like fail-on-retirement: without the input, guard does not fetch
the two manifests at all. It reads the workflow files to know which floating
labels you actually ask for, so it needs the checkout above; without one it says
there is no workflow directory and reports nothing, rather than guessing from the
image alone. What it reports depends on where today sits in the
window, and on the ImageOS the runner exported:
| Phase | ImageOS |
Reported as | Fails the job? |
|---|---|---|---|
| Before the window | — | ::notice, the window and the countdown |
within the threshold |
| Before the window | the new image | ::notice, this runner moved ahead of the announced window |
never |
| In the window | the old image | ::warning, this runner has not moved yet and the diff is still ahead of you |
yes |
| In the window | the new image | ::notice, the migration has reached this runner |
never |
| In the window | absent, or from another job | ::warning, which of the two this job got cannot be told |
yes |
| After the window | the new image | ::notice, the move is done |
never |
| After the window | the old image | ::error, an anomaly rather than drift |
no |
| Any phase | an image from neither end | ::error, the label means something nobody announced |
always |
The threshold counts the days to the start of the window, so it decides the first row only. Once the window is open the change is no longer a countdown, and the rows that fail are the ones where it is still ahead of this runner. A runner that has already moved is green, which is what makes pinning the label, or letting it land, the way out rather than deleting the input.
The last two rows are the ones worth having. A runner still serving Ubuntu 24.04
in December, when the label is supposed to mean 26.04 everywhere, is not a
version bump you should record in the lock file. It does not fail the build
either: GitHub ran both previous latest moves late, nothing in your repo is
broken while it does, and a red build with no threshold that turns it off is one
people fix by deleting the check. An image that is neither end of the window is
different. That is the label meaning something nobody announced, so it fails
whatever the threshold says.
ImageOS describes the runner the step is on, and that runner is serving one
job, so it counts as evidence about ubuntu-latest only when that job asked for
ubuntu-latest. The step above did. A lint job pinned to ubuntu-22.04 in the
same repo gets the window from the calendar and a line saying its own image was
not treated as evidence, rather than a claim that ubuntu-latest has gone
somewhere unrecognised. GITHUB_JOB and GITHUB_WORKFLOW_REF are what tie the
runner to a runs-on: line, and Actions sets both for you. Inside a reusable
workflow the ref names the calling file while the job id lives in the callee, so
the job id decides unless the caller has a job of that name too.
Where the label is reached through a matrix, or the command runs with no
GITHUB_JOB at all, the image is still attributed if it is one of the two the
window names. Both of those give way to a nearer explanation: a matrix leg that
names the observed image, and, with no job id to scope to, any job in the repo
pinned to it. Then you get the calendar and a line saying why the image was not
treated as evidence.
One part of this needs no input at all. When guard finds real tool drift, the
jump from the lock's image to the runner's image is exactly an announced move,
and the job was scheduled from the floating label, it says so, because that is a
table lookup rather than a check you have to opt into. Bump a pinned label from
ubuntu-24.04 to ubuntu-26.04 yourself and you get the drift report without
the explanation, which is the honest answer: that upgrade was yours.
ubuntu-24.04 image 20260907.300.1 -> ubuntu-26.04 image 20260907.131.1
Explained by the scheduled ubuntu-latest migration ubuntu-24.04 -> ubuntu-26.04 (2026-10-19 to 2026-11-19); the tool versions below moved with the image.
CMake 3.31.6 -> 4.4.3 MAJOR
Both labels are named because both images are real, and the version in the lock
was never a version of the one you are on now. There is no commit link beside
the tool for the same reason: no commit to ubuntu-26.04 shipped a difference
against ubuntu-24.04, so a run whose label moved attributes nothing rather
than picking the nearest commit and calling it the cause. A bump within one
label still gets the commit that shipped it.
The step summary carries the same line, above the drift table.
Exit codes and the --fail-on thresholds are untouched by any of this.
--json gains a migration block, and the
step summary gains a table (Label, Status, Move, Window, This runner, Source).
The explanation above has its own explains key next to diffs, which is there
with or without the flag, and is null when nothing announced explains the jump.
Adding windows-latest or macos-latest later is a data change in MIGRATIONS,
nothing else.
Two clocks run on a self-hosted runner. The image one does not apply, since you
built the machine. The agent one does. GitHub requires each new
actions/runner release to be installed within 30 days of publication, and the
docs state the consequence plainly: "If you do not perform a software update
within 30 days, the GitHub Actions service will not queue jobs to your runner."
On GitHub Enterprise Cloud the brownouts have been running since 2026-08-24
and full enforcement lands 2026-09-25. GHEC with Data Residency was enforced
on 2026-07-31
(timeline).
GitHub Enterprise Server is not covered.
Because the rule is rolling there is no minimum to hardcode. 2.337.0 shipped on
2026-08-26 and the next release moves the mark again, so runner-drift asks
the API GitHub added on 2026-09-03:
GET /{scope}/actions/runners/deprecations/{version}, one lookup per distinct
version, cached for the run. Twenty runners on one version cost one call.
The command needs no lock file and no runner of its own, so it runs as a plain
lint job. Three runners, two baked into a container image at 2.335.1 and one
auto-updating at 2.337.0, on 2026-09-09. The dates below are the values the
live API returned that day, and they will have moved since:
$ runner-drift runners --org acme --fail-on-deprecation 30
self-hosted runners — acme (3 runners, 2 versions)
RUNTIME-DUE 2.335.1 x2 arc-linux-1, arc-linux-2
runtime support ends 2026-09-24 (15 days) — jobs stop being queued
update to 2.337.0, published 2026-08-26 — the newest stable actions/runner release
ephemeral runners — change the actions-runner-controller image tag, not the host
OK 2.337.0 x1 build-mac-1 (published 2026-08-26)
no end date returned — this version is current
note: self-hosted runners auto-update by default — at risk are the ones registered with --disableupdate, baked into a VM or container image, or pinned by actions-runner-controller
note: enforcement covers github.com and GitHub Enterprise Cloud, not GitHub Enterprise Server
source: GET /orgs/acme/actions/runners/deprecations/2.335.1
source: GET /orgs/acme/actions/runners/deprecations/2.337.0
runner-drift: 2 self-hosted runner(s) on 2.335.1 lose runtime support on 2026-09-24 (15 days) and --fail-on-deprecation 30 is set.
That last line goes to stderr and the run exits 1, with an ::error annotation
and the same rows as a table in the job summary. Drop --fail-on-deprecation and the annotation becomes a
::warning and the exit code goes back to 0. Only that flag can change the exit
code, and EXPIRED is the one exception: a date already past always fails, the
same rule the image lane uses for a label past its retirement.
Most repositories have no self-hosted runners at all. That is the normal answer, not a failure:
$ runner-drift runners --repo Booyaka101/runner-drift
self-hosted runners — Booyaka101/runner-drift (0 runners)
no self-hosted runners registered — nothing to check; GitHub-hosted runners are not affected
source: GET /repos/Booyaka101/runner-drift/actions/runners
| Status | Means | Fails the job? |
|---|---|---|
OK |
no end date returned, or both dates beyond the window | never |
RUNTIME-DUE |
runtime_deprecates_at inside the window. Jobs stop being queued |
with --fail-on-deprecation |
REGISTRATION-DUE |
registration_deprecates_at inside the window. It keeps running what it has but cannot be re-created |
with --fail-on-deprecation |
EXPIRED |
a date already past | always, whatever the threshold |
UNKNOWN-VERSION |
version is null (never connected), or the API does not recognise the string |
never |
PERMISSION |
the endpoint was refused, plus the permission that would fix it | never (::warning, exit 0) |
RUNTIME-DUE and REGISTRATION-DUE are separate on purpose. A runner past its
registration date still finishes the jobs it has, it just cannot come back. Fold
the two together and an ephemeral or ARC fleet reads as healthy right up to the
next scale-down.
Any row that is not OK also gets an update target, read from actions/runner's
own release list. Drafts and prereleases are excluded, because actions/runner
really does publish them and pointing you at v2.320.1 would be worse than
saying nothing. An OK row does not get one: it already prints why it is fine,
and nagging there would be the universal-deadline noise this report avoids.
A fleet report tells you a runner is about to go quiet. It does not tell you
whose build stops. If a workflow directory is present, runners closes that gap:
it reads every runs-on: set and matches it against each runner's labels using
GitHub's own rule, which is that a job lands on a runner only if that runner
carries every label in the set. The match is per runner, not per group, so a
job needing [self-hosted, linux, gpu] is not reported against a runner that
happens to sit next to a GPU box on the same version.
RUNTIME-DUE 2.335.1 x2 arc-linux-1, arc-linux-2
runtime support ends 2026-09-24 (15 days) — jobs stop being queued
update to 2.337.0, published 2026-08-26 — the newest stable actions/runner release
serves .github/workflows/bench.yml:9 (runs-on: self-hosted, linux, gpu) — arc-linux-1, arc-linux-2
ephemeral runners — change the actions-runner-controller image tag, not the host
Each of those also becomes an ::error on the exact runs-on: line, the same
way --fail-on-retirement annotates a pinned image label, so on a pull request
it shows up next to the job that is going to stop rather than only in the log. A
runs-on: ${{ matrix.os }} is skipped rather than guessed at, and a row that is
OK or UNKNOWN-VERSION annotates nothing. No workflow directory means no join
and no complaint: runners still needs no checkout.
The window defaults to GitHub's own 30 days, so the plain report still tells you
what is coming. --fail-on-deprecation <days> sets the window and makes it
count against the exit code.
runner-versions:
runs-on: ubuntu-latest
steps:
- run: npx runner-drift runners --org acme --fail-on-deprecation 30
env:
GITHUB_TOKEN: ${{ secrets.RUNNER_ADMIN_TOKEN }}The self-hosted runner endpoints are never readable anonymously, and the default
GITHUB_TOKEN cannot read them either. You need one of:
| Scope | Fine-grained token | Classic token |
|---|---|---|
--repo owner/repo |
"Administration" repository permission, read | repo |
--org name |
"Self-hosted runners" organization permission, read | admin:org |
Without one, runner-drift says which permission is missing and which endpoint
it tried, then exits 0. A missing permission is not a deprecation, so it never
fails your build on its own. It does emit a ::warning, so the run is not
silently green either.
$ runner-drift runners --repo actions/runner --fail-on-deprecation 30
self-hosted runners — actions/runner
PERMISSION GET /repos/actions/runner/actions/runners was refused (HTTP 403)
A fine-grained token needs the "Administration" repository permission (read);
a classic token needs the `repo` scope. The default GITHUB_TOKEN has neither.
Self-hosted runners auto-update by default, so most fleets fix themselves and
this command reports OK forever. The population it exists for is the one
GitHub's own required-actions list names: runners registered with
--disableupdate, runners baked into VM or container images, and runners pinned
by actions-runner-controller. Two or more runners reporting the identical
version, or any runner marked ephemeral, is the tell, and runner-drift says
to change the image tag rather than telling you to SSH in and rerun config.sh.
There is no universal deadline here, only your fleet's.
guard uses the same lookup for the runner it happens to be running on. Give it
a token with administration read and it matches $RUNNER_NAME against the
listing and reports that runner's own dates in the summary table and --json.
Without a token, or without the permission, it prints the same ::notice it
printed in 1.1.0 and exits 0. That is the common case, not an error path.
GitHub switched the hosted runners' default action runtime to Node 24 on
2026-06-16 and removes Node 20 from the images on 2026-09-23.
An action that declares runs.using: node20 stops working that day. The catch is
that the runtime is not written in your workflow. uses: actions/checkout@v4
says nothing about Node, and actions/checkout@v4 is node20, as is
actions/upload-artifact@v4 (both read from the ref's own action.yml on
2026-09-13).
runner-drift actions resolves every uses: in the repository to the
runs.using of the action it really names.
$ npx runner-drift actions
scanned 1 workflow file, 3 action reference(s), 3 unique
Node 20 is removed from GitHub-hosted runners on 2026-09-23 (10 days)
WILL FAIL
actions/checkout@v4 node20 -> actions/checkout@v7 (node24)
actions/upload-artifact@v4 node20 -> actions/upload-artifact@v7 (node24)
ok
actions/setup-node@v5 node24
2 of 3 action references stop working in 10 days.
That listing is the report. Like the other lanes, a real run prints its
::error annotations above it, one per failing uses: site, so a failure lands
on the line you wrote rather than in a wall of log.
What it reads:
.github/workflows/**/*.yml|yaml, plus theaction.ymlof every composite under.github/actions/**and the one at the repository root, if there is one. A step you own is yours to fix, and nobody reading only the workflows would ever see it. The root file matters most: its steps run in everybody else's job.owner/repo[/subdir]@refresolves to that exact ref'saction.ymlonraw.githubusercontent.com, falling back toaction.yaml.runs.using: compositeis followed into the composite's ownuses:lines, five levels deep, and so is a reusable workflow (owner/repo/.github/workflows/x.yml@ref). A cycle stops at the repeat../pathis read from the checkout.docker://imageis reported asdockerand never fetched.
Because the whole chain is walked, the report names the step that actually breaks and not just the line you wrote:
WILL FAIL
acme/outer@v1 composite
acme/inner@v2 composite
acme/leaf@v3 node20 no published release declares node24
For each action on a dead runtime it makes one extra api.github.com call for
that repository's latest release, resolves the release's major tag, and reads
what the tag really declares. So the suggestion is -> actions/checkout@v7 (node24) rather than a guess that a newer major must be newer inside. Where no
release declares node24, it says so instead of inventing a target.
A 404, a private repository or a rate limit is reported as unknown with the
reason. It is never a crash, and never a silent pass:
unknown
acme/private@v1 ? no action.yml or action.yaml at acme/private@v1 — wrong ref, or a private repository (set GITHUB_TOKEN)
An unknown does not fail the run on its own, because a proxy or a rate limit
produces the same row as a genuine gap. --fail-on-unknown says the opposite:
in a repository where every reference is supposed to resolve, unchecked is not
good enough.
Exit 1 if anything will fail, 0 otherwise. --warn-only always exits 0, and
--json prints the structured result and nothing else. In a workflow:
- uses: Booyaka101/runner-drift@v1
with:
mode: actionswhich annotates every failing uses: line where it is written, writes a job
summary table, and sets a will-fail-count output.
| Flag | Applies to | Default | Meaning |
|---|---|---|---|
--workflows <path> |
all | .github/workflows |
Workflow directory or a single workflow file |
--lock-file <path> |
init, guard |
runner-lock.json |
Lock file location |
--tools <a,b,c> |
all | detected | Override detection. Aliases (python, npx, clang++, g++, javac, …) resolve to manifest names; anything else is matched against the manifest case-insensitively, so --tools Terraform,Kotlin works |
--label <label> |
init |
detected | Explicit runner label |
--from / --to |
plan |
— | Runner labels to compare. --to is required unless --from is a floating label with an announced migration |
--fail-on <level> |
guard |
never fail | major, minor or any |
--fail-on-retirement <days> |
guard |
off | Fail when a pinned label retires or browns out within N days |
--fail-on-migration <days> |
guard |
off | Fail when a floating label's announced migration starts within N days, or this runner is on the wrong side of it |
--org <name> |
runners |
— | Organization to survey. Mutually exclusive with --repo |
--repo <owner/repo> |
runners |
$GITHUB_REPOSITORY |
Repository to survey |
--fail-on-deprecation <days> |
runners, guard |
report only, window 30 | Set the window and fail when a runner version's support ends inside it |
--warn-only |
actions |
off | Report every failing uses: and still exit 0 |
--fail-on-unknown |
actions |
off | Also fail when a uses: cannot be resolved to a runtime |
--json |
all | off | Machine-readable output |
--no-summary |
guard, runners, actions |
on | Skip the $GITHUB_STEP_SUMMARY write |
--no-update-lock |
guard |
on | Report drift but leave the lock file untouched |
--as-of <date> |
guard, plan, runners, actions |
today | Measure every countdown from this date instead. Nothing else is faked, so it answers "what will this say on the 19th?" ISO only (2026-10-19, 2026-10-19T09:00), read as UTC when no zone is given. |
Exit codes: 0 success (including "drift found" without --fail-on, a refused
permission, and an empty fleet), 1 drift at or above the --fail-on threshold,
a label inside the --fail-on-retirement window, a floating label inside the
--fail-on-migration window, a runner version inside the
--fail-on-deprecation window, an EXPIRED runner version at any threshold, or
an action reference that stops working when Node 20 is removed (actions, unless
--warn-only), an unresolved reference under --fail-on-unknown, 2 usage /
configuration error.
| Input | Default | Meaning |
|---|---|---|
mode |
guard |
Which command the action runs: guard, actions or runners |
fail-on |
'' |
major, minor, any; empty means report only |
fail-on-retirement |
'' |
Days ahead to fail on a label retirement or brownout; empty disables |
fail-on-migration |
'' |
Days ahead to fail on an announced floating-label migration; empty disables |
fail-on-deprecation |
'' |
Days ahead to fail on this self-hosted runner's own agent version; empty disables. Needs github-token to carry administration read |
warn-only |
false |
mode: actions only. Annotate and summarise every failing reference, but never fail the job |
fail-on-unknown |
false |
mode: actions only. Treat a reference that could not be resolved as a failure |
tools |
'' |
Comma-separated override |
lock-file |
runner-lock.json |
Lock file path |
workflows |
.github/workflows |
Scanned when there is no lock yet |
version |
1.4.0 |
npm version of runner-drift to run |
package |
'' |
Override the npm spec, e.g. a .tgz built in the same job. Only useful for testing the action before the version it requests is published |
github-token |
${{ github.token }} |
Rate limit, plus the runner listing for fail-on-deprecation (which the default token cannot read) |
Outputs: lock-file is the lock path that was read or written (mode: guard),
will-fail-count is how many action references stop working when Node 20 is
removed (mode: actions).
mode defaults to guard, so an existing with: block keeps doing exactly what
it did before 1.3.0. mode: runners surveys the repository the job is running in;
for a fleet-wide survey across an org, runners is a plain run: step, shown
above.
The same keys on every path, so nothing has to branch on which shape it got. On a
refusal groups is empty and message / hint carry the reason; on success
message is null and hint is [].
{
"scope": { "kind": "org", "name": "acme", "path": "/orgs/acme" },
"runnersUrl": "https://api.github.com/orgs/acme/actions/runners",
"windowDays": 30,
"failOn": true,
"checkedAt": "2026-09-09T00:00:00.000Z",
"status": "OK",
"message": null,
"hint": [],
"truncated": false,
"surveyedCount": 3,
"totalCount": 3,
"failing": true,
"ghesNote": "enforcement covers github.com and GitHub Enterprise Cloud, not GitHub Enterprise Server",
"autoUpdateNote": "self-hosted runners auto-update by default — at risk are …",
"source": "https://github.blog/changelog/2026-06-12-github-actions-minimum-version-enforcement-timeline-for-self-hosted-runners/",
"groups": [
{
"version": "2.335.1",
"count": 2,
"names": ["arc-linux-1", "arc-linux-2"],
"online": 2,
"busy": 1,
"ephemeral": true,
"labels": ["self-hosted", "Linux", "X64", "gpu"],
"status": "RUNTIME-DUE",
"runtime": { "at": "2026-09-24T15:30:55Z", "date": "2026-09-24", "days": 15, "past": false },
"registration": null,
"unknownVersion": false,
"unparsedDates": [],
"source": "GET /orgs/acme/actions/runners/deprecations/2.335.1",
"publishedAt": null,
"updateTo": { "version": "2.337.0", "publishedAt": "2026-08-26T14:33:29Z" },
"imagePinned": true,
"workflowSites": [
{
"labels": ["self-hosted", "linux", "gpu"],
"expression": false,
"file": ".github/workflows/bench.yml",
"line": 9,
"col": 13,
"runners": ["arc-linux-1", "arc-linux-2"]
}
]
}
]
}surveyedCount is what was actually classified and totalCount is what the API
claims the fleet is. They differ when the listing was truncated at the page cap or
when total_count disagrees with the objects returned, and the text report says so
either way. runtime.at keeps the full timestamp; the text report trims it to the
date.
The whole survey, in the order the text report prints it: failing first, then
unknown, then ok. Trimmed here to one failing and one ok reference (the third,
actions/upload-artifact@v4, is the same shape as the first).
{
"removalDate": "2026-09-23",
"defaultSwitchedAt": "2026-06-16",
"source": "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/",
"checkedAt": "2026-09-13T00:00:00.000Z",
"daysLeft": 10,
"root": ".",
"workflowPath": ".github/workflows",
"actionsPath": ".github/actions",
"files": [
{
"file": ".github/workflows/ci.yml",
"kind": "workflow"
}
],
"missing": false,
"workflowsMissing": false,
"readErrors": [],
"totalReferences": 3,
"uniqueReferences": 3,
"counts": {
"fail": 2,
"unknown": 0,
"ok": 1
},
"references": [
{
"ref": "actions/checkout@v4",
"kind": "remote",
"using": "node20",
"source": "https://raw.githubusercontent.com/actions/checkout/v4/action.yml",
"children": [],
"status": "fail",
"where": [
{
"file": ".github/workflows/ci.yml",
"line": 8,
"col": 15
}
],
"upgrade": {
"available": true,
"checked": true,
"ref": "actions/checkout@v7",
"tag": "v7",
"latestRelease": "v7.0.1",
"using": "node24",
"url": "https://raw.githubusercontent.com/actions/checkout/v7/action.yml"
}
},
{
"ref": "actions/setup-node@v5",
"kind": "remote",
"using": "node24",
"source": "https://raw.githubusercontent.com/actions/setup-node/v5/action.yml",
"children": [],
"status": "ok",
"where": [
{
"file": ".github/workflows/ci.yml",
"line": 9,
"col": 15
}
]
}
],
"failing": true
}using is what the action's own action.yml declares, source is the exact
file that was read, and where is every line the reference is written on, so a
reference used by six jobs is resolved once and still reports all six. A
composite carries its steps in children, recursively, and its status is the
worst status underneath it. upgrade is present only on a reference that will
fail: available: false with a reason means the lookup ran and there is no
release declaring node24, while checked: false means the lookup itself could
not be made.
{
"schemaVersion": 1,
"label": "ubuntu-22.04",
"imageOS": "ubuntu22",
"imageVersion": "20260623.199.1",
"tools": {
"Python": { "versions": ["3.10.12"], "source": "probe", "command": "python3 --version" },
"Clang": { "versions": ["13.0.1", "14.0.0", "15.0.7"], "source": "manifest" }
},
"updatedAt": "2026-08-05T02:52:15.070Z"
}source records how the version was observed. guard probes the tool directly
(python3 --version, clang --version, java -version, …) when it can, because a
manifest says what the image was built with while 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 the source is recorded so a source change is never
mistaken for a version change.
A tool the manifest cannot answer for is left out of the diff rather than
reported as removed: a rate limit is not a tool being deleted. That covers both
an unreadable manifest and a tool with no probe recipe that the readme does not
list, since the readme is a curated page whose headings get renamed. Those names
go in --json under notCompared, the reason is a ::warning in the log, and
they keep the entry the lock already had so the next run does not read them as
added. A tool that does have a probe recipe is treated differently: the probe
looked for it on this machine and did not find it, so a manifest that does not
list it either is reported as removed.
ubuntu-22.04, ubuntu-24.04, ubuntu-26.04 (+ -arm), windows-2022,
windows-2025, macos-14, macos-15, macos-26 (+ -arm64).
Deadline data covers the images with an announced retirement date:
ubuntu-22.04 (+ arm) and macos-14 (+ arm64, -large, -xlarge). The
large/xlarge labels have no public manifest, so they get the retirement
countdown and --fail-on-retirement, not the tool diff. Every other label
diffs fine, it just has no countdown.
Migration data covers the floating labels GitHub has announced a move for:
ubuntu-latest (ubuntu-24.04 -> ubuntu-26.04, 2026-10-19 to 2026-11-19,
#14748). That issue covers
the x64 label only, so ubuntu-latest-arm has no entry and is left alone. MIGRATIONS in
src/labels.mjs is separate from the retirement DEADLINES table, because a
migration is a dated window between two live images and a retirement is an end
date for one.
- Floating labels are refused, on purpose, except where GitHub said otherwise.
ubuntu-latest/macos-latestare re-pointed by GitHub without notice, soplanwill not guess what they mean — pass the concrete label. The exception is a label inMIGRATIONS, where GitHub has published both ends and the dates; thereplan --from ubuntu-latestdiffs the two images it named, and still refuses a floating--to.guarddoes not need to guess either way: it reads the real label from the runner'sImageOSenv var at run time. - Migration windows are a hardcoded table too, and only cover announced moves.
GitHub publishes no feed for these, so
MIGRATIONSneeds a release when a new move is announced. A floating label with no entry behaves exactly as it did before 1.4.0. - Image deadlines are a hardcoded table; runner-version deadlines are not.
The
runs-onlabel dates insrc/labels.mjsare transcribed from #14254 and #13518 and printed with their source URL. GitHub publishes no machine-readable feed for those, so if it moves an image date the table needs a release. The self-hosted runner agent dates are the opposite: they come fromGET /{scope}/actions/runners/deprecations/{version}on every run and are never stored, because the 30-day rule is rolling and any number baked into this package would be wrong by the nextactions/runnerrelease. registration_deprecates_atis documented but not yet populated. The schema says string-or-null, and on 2026-09-09 the live API omitted the key entirely for every version from2.325.0to2.337.0, returning onlyruntime_deprecates_at.REGISTRATION-DUEis implemented and tested against that shape, and it will start firing the day GitHub fills the field in. Meanwhile a version below the2.329.0registration floor is called out on its own line.- Detection is a targeted line scan, not a full YAML parse (the package has zero
dependencies). It handles inline, flow-sequence and block-sequence
runs-on:, plus thegroup:/labels:mapping form (a group names a pool, so only the labels under it are read), and resolvesruns-on: ${{ matrix.os }}by harvesting label-shaped values from the same file, skipping comments, block scalars and the keys that hold prose (run,name,if). If it misses something,--toolsand--labeloverride it completely. Which tools get diffed is--toolsfirst, then the ones the lock file already records, then the scan. - Resolving
uses:needs the network, and says so when it cannot. Each unique remote reference is oneraw.githubusercontent.comread of that exact ref'saction.yml, plus, for the failing ones only, oneapi.github.comrelease lookup. A 404, a private repository, an offline box or a rate limit is reported asunknownwith the reason on the row. It is never guessed and never silently counted as fine. Unauthenticatedapi.github.comallows 60 calls an hour, so a repository with many distinct failing actions wantsGITHUB_TOKENset. - A
${{ }}reference is undecidable and is reported that way.uses: ${{ matrix.action }}only has a value at run time, so there is noaction.ymlto read. Same for a private action you have no token for: 404 is indistinguishable from a typo in the ref, and the row says both possibilities. - What your own
action.ymldeclares is not checked, only what it calls. The root action's steps are read, so a composite of yours that callsactions/checkout@v4is caught. Its ownruns.using:is not classified, because the question this command answers is which references stop working, and your action is not a reference here. A job withuses: ./makes it one, which is what this repository does. - Composites nest five levels deep, then stop. A chain deeper than that, or a
cycle, is
unknownwith the reason rather than a hang, and an unresolved chain counts against its parent instead of passing. Composites also get no-> owner/repo@vNof their own:using: compositesays nothing about what its steps resolve to, so the upgrade sits on the child row that actually declares a runtime. - Self-hosted runners have their own lane, not a skip. There is still no
ImageVersionto diff, soguardprints its::noticeabout the image, then checks the runner's own agent version against GitHub's dates. That needs a token with administration read; without one it falls back to the 1.1.0 behaviour, which is the::noticeand exit 0. - Repo and org scope only, because that is all there is. The 2026-09-03
changelog says the endpoint is callable at enterprise level too, and asking
api.github.comfor/enterprises/{slug}/actions/runners/deprecations/{v}does return a route-specificdocumentation_url. But GitHub's own OpenAPI description forapi.github.comcontains only the/orgs/and/repos/deprecations paths, and/enterprises/{enterprise}/actions/runnersappears solely in the GHES spec — where there is no deprecations endpoint at all and this enforcement does not apply. So there is nothing to call at enterprise scope on github.com or GHEC, and--enterpriseis deliberately absent rather than pending. Checked 2026-09-09 against both published specs. - Source files are not scanned for pinned runner versions. If your Dockerfile,
Helm values or Terraform pins an
actions/runnerversion,runner-driftwill not find it there. It reads what your runners actually report. For the scanning angle,canblmz1/gh-runner-eolalready does it well. - A fleet larger than 1000 runners is reported as a prefix. The listing
follows pagination to ten pages of 100. Past that the report says how much it
saw of how many, rather than quietly surveying the first slice. It also says so
when the API's
total_countdisagrees with the objects it actually returned. - Azure DevOps is out of scope, even though the same images and the same deprecation apply there.
- No auto-fix.
runner-drifttells you exactly what moved and who moved it; the migration is yours. - Multi-version probes report one version.
clang --versionreports the default clang, while the manifest lists all three. That is why thesourcefield exists — compare like with like.
git clone https://github.com/Booyaka101/runner-drift
cd runner-drift
node --test # 270 tests, fully offline against recorded real fixturesTests run against four real manifest snapshots in test/fixtures/
(Ubuntu2204 at two different image versions, Ubuntu2404, macos-15), so the
golden plan output is deterministic while the live path re-fetches. The runner
lane works the same way: test/fixtures/runners/deprecations-recorded.json is
the verbatim live response for eight versions, recorded 2026-09-09, so the dates
the tests assert on are GitHub's own. The fleet listings alongside it are built
to the documented schema, because this account owns no self-hosted runners to
record; the empty listing in the recorded file is real.
The runner tests stub globalThis.fetch rather than the module boundary, so
src/runners.mjs is exercised through src/http.mjs and the 401, 403, 404 and
rate-limit paths are the real ones.
The action-runtime tests work off a fixture repository in
test/fixtures/actions-repo/ and a routing table of action.yml bodies, which
covers what the live network cannot reproduce on demand: a nested composite whose
grandchild is node20, a cycle, a private repo that 404s, a reusable workflow, a
subdirectory action spelled action.yaml, and a SHA pin with a trailing version
comment. The runtimes the README claims for the real actions were read from the
live action.yml of each ref on 2026-09-13.
MIT — see LICENSE.