diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18efae7..a8e3c0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,6 +134,38 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} + migration: + # Deliberately floating. Every other job here pins its image; this one is the + # lane's subject, and reading ImageOS off a runner that GitHub is in the + # middle of re-pointing is the half no test can reach. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: plan the announced ubuntu-latest move against the live manifests + run: node src/cli.mjs plan --from ubuntu-latest + env: + GITHUB_TOKEN: ${{ github.token }} + + # The exit code is deliberately not asserted: it is 1 while the window is + # ahead of this runner and 0 once the move has reached it, so pinning it + # would turn the rollout itself into a red build. What must hold on every + # run is that the lane resolved the label and named its source. + - name: guard --fail-on-migration on a real floating runner + run: | + set +e + out="$(node src/cli.mjs guard --tools node --fail-on-migration 30 --no-update-lock 2>&1)" + code=$? + printf '%s\n' "$out" + echo "exit $code" + grep -q 'ubuntu-latest' <<<"$out" || { echo 'the migration lane said nothing'; exit 1; } + grep -q 'actions/runner-images#14748' <<<"$out" || { echo 'no source named'; exit 1; } + env: + GITHUB_TOKEN: ${{ github.token }} + runners: runs-on: ubuntu-24.04 steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb700c..91a363e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,381 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] — 2026-09-20 + +### Added + +- **The `ubuntu-latest` migration lane.** GitHub's changelog of 2026-09-17: *"The + ubuntu-latest label will migrate from Ubuntu 24.04 to Ubuntu 26.04. This + migration will roll out gradually between October 19 and November 19, 2026."* + ([actions/runner-images#14748](https://github.com/actions/runner-images/issues/14748)). + + For that month `ubuntu-latest` is two different operating systems depending on + which runner the job lands on, and no workflow file changes. The kernel goes + `6.17.0-1022-azure` -> `7.0.0-1012-azure` and systemd `255.4-1ubuntu8.17` -> + `259.5-0ubuntu3.4`, while Docker Buildx, the AWS and Azure CLIs, Rust and Java + 17 are the same on both images. That is exactly the kind of change this tool + exists to show you before it lands. + + `MIGRATIONS` in `src/labels.mjs` records the announced moves: the floating + label, the two concrete labels it moves between, the window and the source + issue. It sits beside the retirement `DEADLINES` table rather than inside it, + and shares no keys with it: a retirement is an end date for one image, a + migration is a dated window between two live ones. Today it has one entry. + `windows-latest` and `macos-latest` are a data addition when GitHub announces + them. + +- **`plan --from `** no longer refuses. Where `MIGRATIONS` names + both ends, `plan` resolves them and diffs the two concrete images, so the + output is the kernel and systemd deltas rather than a bare warning: + + ``` + $ 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 + ``` + + A floating `--to`, or a floating `--from` with no announced migration, is + refused exactly as before. `plan` also gained the three image-header rows (OS, + kernel, systemd) for every comparison, not just this one, since they were being + parsed already and dropped. + +- **`guard --fail-on-migration `**, and the matching `fail-on-migration` + action input. Off by default, like `fail-on-retirement`: without it `guard` + does not fetch the two manifests at all. With it, each floating `runs-on:` line + is annotated according to where today sits in the window and which `ImageOS` + the runner exported: pending, this runner has not moved yet, the migration has + reached this runner, done, or, past the window on the old image, an anomaly. + A rollout running past its announced end is annotated `::error` and reported, + and does not fail the build: GitHub ran both previous `latest` moves late, and + a failure no threshold turns off is one people remove the check over. An image + that is neither end of the window does fail whatever the threshold, because + that is the label meaning something nobody announced. + + `ImageOS` describes one runner, and that runner is serving one job, so it is + read as evidence about a floating label only when the job it is running asked + for that label: `GITHUB_JOB` and `GITHUB_WORKFLOW_REF` say which job, and the + scan already knows which job each `runs-on:` belongs to. A lint step pinned to + `ubuntu-22.04` in a repo that uses `ubuntu-latest` elsewhere reports the window + from the calendar alone and says why, instead of announcing that + `ubuntu-latest` has gone somewhere unrecognised. Where the label is reached + through a matrix, or the check runs with no `GITHUB_JOB` at all, the image is + still attributed if it is one of the two the window names. + + When the observed image explains the tool drift `guard` just found, the report + says so, rather than leaving a page of major bumps looking unexplained. That + line needs no input, only the job that was actually moved: a lock recorded on + one side of an announced move, a runner on the other, and a workflow that asked + for the floating label. A repo that bumps a pinned `ubuntu-24.04` to + `ubuntu-26.04` by hand is not told GitHub did it. + + ``` + 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. + ``` + + `--fail-on`, `--fail-on-retirement` and `--fail-on-deprecation` are untouched, + and so are all four output formats' existing contents: the migration is a new + annotation group, a new step-summary table (Label, Status, Move, Window, This + runner, Source) and a new `migration` key in `--json`. + +- **`--as-of `** on every command that counts down to something (`guard`, `plan`, `runners`, `actions`). It moves the clock every countdown is + measured from and nothing else, so `plan --from ubuntu-latest --as-of + 2026-10-19` answers what the report will say on the first day of the rollout + without pretending the run happened then. A date, or a time with no zone, is + read as UTC, which is the calendar the countdowns themselves use. It is also + what makes the migration tests deterministic. + +- `init` refusing a floating label now points at the migration when there is one, + instead of only saying "pass a concrete label". + +### Changed + +- Every countdown is now whole calendar days from today, not hours divided by 24 + and rounded. A deadline dated 2026-11-19 reads "0 days" for the whole of the + 19th rather than flipping to "1 day ago" at midday, and the printed date and + the number beside it can no longer disagree. Some `runners` countdowns move by + a day: `2.335.1 runtime ends 2026-09-24` was "(16 days)" on 2026-09-09 and is + now "(15 days)", which is the number of days you can actually still run it. + `--fail-on-deprecation` and `--fail-on-retirement` compare against that same + number, so a threshold sitting exactly on a boundary can fire a day later than + it did in 1.3.0. + +- The exported `runGuard` now writes the lock file unless the caller passes + `{ 'update-lock': false }`, on the drift path as well as the first run. It used + to write on a first run and stay silent on a drift run, because the drift path + read the key as a plain boolean and a caller building options by hand has + neither the flag nor the parser default. The CLI is unaffected: it sets the key + either way. + +- `resolveManifestVersions` moved from `src/cli.mjs` to `src/manifest.mjs`, where + the rest of the manifest reading lives, and is re-exported from `cli.mjs` so + the public surface is unchanged. The migration lane needed it and importing it + from the CLI would have made a cycle. + +### Fixed + +- `guard --no-update-lock` wrote the lock file anyway on the very first run, when + there was nothing to update yet. The README said the flag leaves the lock file + untouched, so a lint job that asked for a report got a file to decide about. + It now writes nothing, says so, and still reports the baseline it observed; + `--json` gained a `written` boolean, on the drift payload as well as the + baseline one, so a reader can tell a report from a recorded run, and the step + summary no longer says the tools were "locked in" a file that does not exist. + +- A workflow whose `runs-on:` is a matrix expression had every label-shaped word + in the file read as a runner it asks for, including the ones in comments, in + `run:` scripts and in step names. That put retirement and migration + annotations on lines nobody can act on, and, once the migration lane existed, + could attribute a runner's image to a label the repo never uses. Comments, + block scalars and the prose keys (`run`, `name`, `if`) are now skipped, and a + prose key takes the indented lines below it with it, since a plain scalar + wraps onto them as readily as a `|` block does. The + rest of the file is still read, because a label reaches `runs-on:` through a + `workflow_call` input default or an `env:` value as well as through `matrix:`. + +- A trailing comment on a `runs-on:` line was part of the label: + `runs-on: ubuntu-latest # floating on purpose` parsed as the whole string, so + the line was not a floating site and the migration lane had nothing to say + about it. Both scanners strip the comment first. + +- The step summary on a drift run said "Lock file `x` updated to image `y`" + under `--no-update-lock`, which had left the file alone. It now says the file + was left where it was, and why. + +- The line that credits the migration for the tool drift followed the same job + rule as the annotations only by label. A matrix leg that names the new image + itself, next to the floating label, was GitHub moving you; now it is your own + pin, and the line is withheld. + +- A key at the job indent under a *later* top-level block was recorded as a job + id. `x-templates:` after the jobs map, with a `build:` under it, produced + `runs-on:` sites labelled as job `build`, which is a real job id in the same + file. A fabricated id can match `GITHUB_JOB`, so it could have pointed the + migration lane at the wrong `runs-on:` line. The map now ends where YAML ends + it, at the next top-level key. + +- The line that credits the migration for tool drift read `direct` without a job + id, so a repo with one job on `ubuntu-latest` and another pinned to + `ubuntu-26.04` was told GitHub moved it when the pinned job was the one that + ran. With no `GITHUB_JOB` either job explains the image, and the lane that + attributes `ImageOS` has always said so. Now both do. + +- Every countdown said "(1 days)" on the last day before the date it counts to, + including the annotation titles and the retirement lane's "1 days left". + +- `--tools constructor`, or a lock file with a tool of that name, crashed in the + manifest resolver: the candidate-names table answered with a function, and a + function is not a list of names. Every table keyed by a tool or a command reads + as data now, like the label ones. That includes the three the scanner and the + prober use, so a `run:` step calling conda's `constructor` CLI, or a + `uses: constructor@v1`, no longer becomes a detected tool whose probe recipe + is a function. + +- A manifest read that failed was remembered as failed. The run reads each + manifest once, and the memo held the rejected promise, so the lane that asked + second got the first one's network error without a request of its own. A 502 + in the migration lane could have left the drift lane with no manifest at all, + which reads as every locked tool having been removed. Only a read that worked + is kept. + +- A refused or rate-limited attribution lookup took the manifest read down with + it. Pinning the manifest to the commit that shipped this exact image version + is an improvement on the label's current readme, not a prerequisite, but both + sat in one `try`, so a 403 left every manifest-only tool unobserved and the + diff reported each of them as REMOVED, which is MAJOR. `--fail-on major` red + the build over a rate limit. The readme fallback now runs whatever the API + did. + +- A tool nothing could observe was still diffed as REMOVED. When both manifest + reads fail, every manifest-only tool in the lock was warned about as skipped + and then reported as removed in the same run, so `--fail-on major` red the + build over an `ECONNRESET` even with the readme fallback in place. Unobserved + is not removed: those tools are left out of the diff, and they keep the entry + the lock already has so the next run does not read them as added either. + + A tool the manifest did answer for, by not listing it, is the same case when + nothing else could have seen it: with no probe recipe the readme is its only + observer, and the readme is a curated page whose headings get renamed. Those + are left out of the diff too. A tool that does have a probe recipe was looked + for on the machine and not found, so a manifest that does not list it either + is still reported as REMOVED. `--json` names everything left out under + `notCompared`, since the `diffs` array would otherwise be quietly shorter than + the lock with the reason only on stdout. + +- A job id the run could not be placed by let one workflow file speak for + another. Inside a reusable workflow `GITHUB_WORKFLOW_REF` names the caller, + so the job is matched on its id alone, and an id is unique in a file rather + than in a repository. A `build` job pinned to `ubuntu-26.04` in `release.yml` + could not veto the `build` job on `ubuntu-latest` in `ci.yml`, because only a + matrix leg counted as a rival explanation. A run that cannot be placed in one + file now treats any job of that id pinned to the observed image as the nearer + explanation, exactly as it does when there is no job id at all, and says so + rather than reporting the label as migrated. + +- A repo with no tools to watch exited 2 from `guard` before the retirement and + migration gates could report, so the annotation was printed, the reason line + was not, and CI saw a usage error instead of a failed check. Both gates read + the workflow files rather than the tool list, so they now report and decide the + exit code; the advice about `--tools` is still printed. + +- A manifest header field one side does not publish was reported as a removal. + `plan --from ubuntu-24.04 --to windows-2025` said the kernel and systemd had + gone, in MAJOR red, when Windows manifests simply have no such line. + +- A run that could not be placed in a file took a plain `runs-on: ubuntu-latest` + from any file with a job of the same id. Two `build` jobs, one on + `ubuntu-latest` and one on `windows-latest`, and a run reporting a caller the + scan does not hold: the Windows runner's image was attributed to + `ubuntu-latest` and reported as an image nobody announced, an `::error` that + fails at any threshold, annotated on a workflow the run never touched. A job id + that names jobs in files which do not all ask for the label now settles + nothing, and the run says so. + +- With no `GITHUB_JOB`, a matrix leg that can be scheduled onto the observed + image was reported as a workflow asking for it by name. + +- A `uses:` job passing a runner label to a reusable workflow lost it whenever + the file also had a matrix job. Such a job has no `runs-on:` of its own, so the + `with:` value is the only record of the runner the file asks for, and the + retirement lane stopped naming it. + +- One matrix job let every label-shaped value in the file speak for the job it + sat in. The fallback is a token scan, so a sibling job pinned to + `runs-on: ubuntu-latest` with an `env:` naming `ubuntu-26.04` looked like a job + reaching the floating label through a matrix with a rival leg: the runner's own + image was discarded and `--fail-on-migration` failed a runner that had already + moved. A job whose `runs-on:` is a plain label is scheduled by that label and + takes nothing from the fallback. The retirement lane stops annotating those + `env:` values as pinned images too. + +- A matrix axis called `name`, `run` or `if` was read as prose and dropped, so a + `runs-on: ${{ matrix.name }}` over image labels found nothing. Under `matrix:` + every key is a dimension the job varies over, and the prose keys only hold + prose outside it. + +- A job called `matrix` turned off the prose keys for its whole body, since the + scan matched the key name anywhere. Only `strategy.matrix` is a matrix now, so + a step title in that job is prose like any other. + +- Running both lint lanes with no workflow directory reported the one missing + directory twice. The scan is memoized for the run, and the notice is too. + +- A workflow title mentioning a label was read as a runner the workflow asks + for. `run-name: nightly build on ubuntu-22.04` annotated the title line with a + retirement `::error` for an image the file never uses, as did an input's + `description:`. Both are prose keys now, alongside `run`, `name` and `if`. + This one predates 1.4.0: the unfiltered scan read them the same way. + +- A label written under a key named `name`, `run` or `if` was dropped even when + that key held a map rather than prose. 1.4.0 taught the matrix fallback to skip + the keys that hold shell and titles, and an empty one swallowed everything + indented under it, so a `workflow_call` input called `name` took its `default:` + with it and both `--fail-on-retirement` and `--fail-on-migration` went silent + for that file. An empty prose key now only owns a body that is not itself a + map. + +- `--as-of` accepted a date `Date.parse` reads in local time, which is the + off-by-a-day the flag normalises to UTC to avoid: `--as-of "Oct 19 2026"` + measured from the 18th east of Greenwich. It takes ISO dates and date-times + now, with or without a zone, and refuses the rest. + +- The migration step-summary table badged the phase, not the outcome, so a runner + still serving the old image after the window closed showed `✅ settled` in the + row beside its own `::error`. The column is headed Status now and names the + state: moved early, not yet, in window, migrated, settled, stale, unexpected. + +- A `runs-on:` taken from an expression resolved outside the jobs map lost its + job. A `workflow_call` input default and a top-level `env:` value both sit + above `jobs:`, so the line the label was read from carried no job id, and with + `GITHUB_JOB` set the runner's image was discarded with the note that the job + had not run on the floating label. On a reusable build workflow that had + already migrated, `--fail-on-migration` reddened the build for the whole + rollout month, and only when run inside a job, which read as flakiness. Such a + label now belongs to every job whose `runs-on:` is an expression. + +- The same sentence was used when this run's own job asks for the floating label + outright and the namesake job reaches the observed image through a matrix. It + claimed a matrix this job does not have and a pin the other one does not have. + A namesake is now described by what it can be scheduled onto when it is not a + plain pin. + +- A job that reaches the floating label through its own matrix was explained as + two workflows sharing a job id whenever the run could not be placed in a file, + which is every run with no `GITHUB_WORKFLOW_REF` and every run inside a + reusable workflow. Withholding the image was right, the sentence was not: a + namesake job now has to ask for the observed label with a plain `runs-on:` + before it is named as one. + +- Mid-migration, the drift lane attributed the change to the wrong image's + history. A lock recorded on `ubuntu-24.04` and a runner on `ubuntu-26.04` are + two operating systems, but guard looked the locked image version up in the + 26.04 commit list, found nothing, and fell back to the nearest 26.04 commit, + so every moved tool was stamped with a commit that did not ship it. The header + had the same shape: `ubuntu-26.04 image 20260720.247.2 -> 20260907.131.1`, + where the first version is a 24.04 image. A run whose label moved names both + labels now and attributes nothing, in the log, the step summary and `--json`, + which gained `fromLabel`. + +- A tool named after a property of `Object` crashed the drift diff. The maps the + diff is built from are keyed by tool name, which comes from the lock file, + `--tools` or the scanner, and they were plain objects: `'constructor' in + lockedMap` was true, `diffTool` got a function where a version list belongs, + and guard exited 2 with a report-this-bug prompt. The attribution map had the + same hole and was the next line to crash. Those maps have no prototype now, + and the two that arrive as arguments to a public function are read as data, + so such a tool diffs like any other. `__proto__` was worse than a crash: the + entry was silently dropped on the way in, so a locked tool of that name was + never compared at all. + +- Every table keyed by a runner label answered `__proto__` and `constructor` + with something truthy, so `plan --from constructor` took the resolved-migration + branch and then reported that `--from` was missing. `deadlineFor`, + `pathForLabel`, `migrationFor`, `nextBrownout` and `retirementStatus` all read + their tables as data now. The last two returned a deadline with no migration + targets, and `guard --fail-on-retirement` crashed writing the annotation that + tells you where to move. + +- `ImageOS=__proto__` resolved to a label. The env value indexed the + `ImageOS` -> label table directly, so any property of `Object.prototype` came + back truthy, and the migration lane called it an image nobody announced: + `::error`, exit 1, and a summary cell containing a function body. The lookup + is an own-key check now, in one place both lanes use. + +- A job id that two workflow files both use was told apart by the file only when + the scan happened to hold a pinned site in the file this run came from. The + migration lane handed the ownership rule the pinned sites and one floating + label's, so a `build` job on `windows-latest` in `release.yml` could claim the + `build` job on `ubuntu-latest` in `ci.yml`, and report an image from outside + the window: an `::error` and a failed build, in a repo where nothing was wrong. + +- A job running inside a reusable workflow had its image discarded. Actions + reports the *calling* workflow in `GITHUB_WORKFLOW_REF` while `GITHUB_JOB` is + the id inside the callee, and the scan matched the file first, so the real + `runs-on:` line was rejected as somebody else's job. The job id now decides, + and the file is only used to break a tie when the caller has a job of the same + name. + +- With no `GITHUB_JOB` to scope to, a runner's image was read as evidence about + the floating label whenever it was one of the two the window names, even + though a sibling job pinned to that exact image explains it just as well. A + repo with a `compat:` job on `ubuntu-26.04` could report `ubuntu-latest` as + migrated a fortnight early. Any `runs-on:` naming the observed image now takes + the evidence away, and the note that used to assert "this check did not run on + ubuntu-latest", which nothing in that case knew, says what is actually missing. + +- `guard --fail-on-migration` diffed the tools named in the workflow files even + when the lock file listed a different set. The drift lane has always preferred + the lock, since that is the list it is about to compare. Both lanes now read + `--tools`, then the lock, then the scan. + +[1.4.0]: https://github.com/Booyaka101/runner-drift/releases/tag/v1.4.0 + ## [1.3.0] — 2026-09-13 ### Added diff --git a/README.md b/README.md index 77974b6..76665fd 100644 --- a/README.md +++ b/README.md @@ -24,19 +24,24 @@ brownouts starting 2027-03-23](https://github.com/actions/runner-images/issues/1 ``` $ 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 -> 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) -255 days left (230 until the first brownout) — deprecation began 2026-09-17; see https://github.com/actions/runner-images/issues/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. Note what is **not** there: CMake. +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. @@ -46,7 +51,7 @@ rolling: you have 30 days from each `actions/runner` release to install it, or [the Actions service stops queueing jobs to your runner](https://docs.github.com/en/actions/reference/runners/self-hosted-runners). `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](#5-runner-drift-runners--self-hosted-agent-versions). +[Self-hosted agent versions](#6-runner-drift-runners--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](https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/), @@ -56,7 +61,16 @@ 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](#6-runner-drift-actions--which-uses-survive-the-node-20-removal). +[which `uses:` survive](#7-runner-drift-actions--which-uses-survive-the-node-20-removal). + +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](https://github.com/actions/runner-images/issues/14748). +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](#5-when-a-floating-label-moves-under-you). - No account, no hosted service, no paid tier. Two endpoints only: `raw.githubusercontent.com` and `api.github.com`. @@ -83,7 +97,7 @@ npm i -g runner-drift # or globally ```bash $ runner-drift init Scanned 2 workflow file(s) in .github/workflows -Runner label: ubuntu-22.04 (image 20260720.234.2, 22.04.5 LTS) +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) @@ -153,8 +167,13 @@ 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](#5-when-a-floating-label-moves-under-you). + ### 4. Fail before the brownout Deprecated images get scheduled brownouts before removal: `macos-14` jobs fail @@ -191,11 +210,139 @@ When only a brownout falls inside the threshold the annotation is a `::warning` 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 they are never flagged; -neither is `self-hosted`. `runs-on: ${{ matrix.os }}` is resolved from the +`ubuntu-latest` and friends float past retirements, so this lane never flags +them; they get their own lane, [below](#5-when-a-floating-label-moves-under-you). +`self-hosted` is never flagged at all. `runs-on: ${{ matrix.os }}` is resolved from the matrix values in the same file. -### 5. `runner-drift runners` — self-hosted agent versions +### 5. When a floating label moves under you + +`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](https://github.com/actions/runner-images/issues/14748). +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 ` scans the `runs-on:` lines for floating +labels with an announced move and fails while the window is still ahead of you: + +```yaml + 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 30 +``` + +It 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. + +### 6. `runner-drift runners` — self-hosted agent versions 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 @@ -223,7 +370,7 @@ 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 (16 days) — jobs stop being queued + 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) @@ -232,7 +379,7 @@ note: self-hosted runners auto-update by default — at risk are the ones regist 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 (16 days) and --fail-on-deprecation 30 is set. +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 @@ -285,7 +432,7 @@ 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 (16 days) — jobs stop being queued + 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 @@ -353,7 +500,7 @@ 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. -### 6. `runner-drift actions` — which `uses:` survive the Node 20 removal +### 7. `runner-drift actions` — which `uses:` survive the Node 20 removal 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](https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/). @@ -451,9 +598,10 @@ summary table, and sets a `will-fail-count` output. | `--lock-file ` | `init`, `guard` | `runner-lock.json` | Lock file location | | `--tools ` | 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