1.4.0: the ubuntu-latest migration lane - #11
Merged
Merged
Conversation
GitHub is re-pointing ubuntu-latest from Ubuntu 24.04 to 26.04 between 2026-10-19 and 2026-11-19 (actions/runner-images#14748). For that month the label means either image depending on which runner a job lands on, and nothing in the lock file can tell you which one you got. MIGRATIONS in src/labels.mjs holds the announced windows, keyed by floating label, separate from DEADLINES because a deadline retires a concrete label and offers destinations you pick between, while a migration is a floating label being re-pointed on dates you do not. One entry today; windows-latest and macos-latest are a data addition. - plan --from ubuntu-latest resolves the pair and diffs the two images, kernel and systemd included, instead of refusing the label. - guard --fail-on-migration <days> classifies the window against the ImageOS the runner exported: pending, moved-early, not-yet-migrated, ambiguous, migrated, settled, stale, unexpected. Off by default. fail-on, fail-on-retirement and fail-on-deprecation are untouched. - Drift that the announced move explains says so, from the table, with no flag and no extra request. - --as-of <date> moves every countdown, so the month can be rehearsed now. - All four output formats carry it: text, annotations on the runs-on: line, step summary, --json. 319 tests pass, and the same 319 pass with fetch replaced by a throw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found reviewing 1.4.0: with no lock file yet, the baseline branch wrote one regardless of the flag, so a lint job that asked for a report got a file to decide whether to commit. The README already said the flag leaves the lock untouched. writeLock's normalising body becomes lockPayload so the no-write path can report the same shape in --json, which gains a `written` boolean beside `baseline`. The DriftError message-plus-hint line was written out three times; it is errorText in http.mjs now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
guard read the runner's ImageOS as evidence about every floating label in the repo. A lint step pinned to ubuntu-22.04 therefore reported ubuntu-latest as serving an unrecognised image: an anomaly, which fails whatever the threshold. Label sites now carry the job they belong to, and GITHUB_JOB plus GITHUB_WORKFLOW_REF say which job this runner is serving. A matrix leg, or a run with no GITHUB_JOB, still attributes the two images the window names, since those are evidence the runner did come from the floating label. The "Explained by the scheduled migration" line is gated the same way, so a hand-bumped pin from ubuntu-24.04 to ubuntu-26.04 is no longer credited to GitHub. Countdowns are whole calendar days from today rather than rounded hours, so the printed date and the number beside it agree: some runners countdowns move by one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
attributeImageOS checked "is this one of the window's two images" before "did this job ask for the label", so a job pinned to ubuntu-24.04 after the window closed reported ubuntu-latest as stale: an error, and an exit 1. The endpoint rule is only there to rescue the two cases where the job cannot be matched, so it now lives inside them. The CI migration lane ran under GitHub's default bash -e, so guard exiting 1 while the window is ahead killed the step before the assertions the comment above it describes. set +e, the idiom the runners lane in the same file uses. The step summary now carries the survey notes, which is where "This runner: —" gets explained, and the changelog no longer claims --as-of on every command. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A file with one expression runs-on had every label-shaped word in it treated as a runner the workflow asks for. A comment saying the repo left ubuntu-latest years ago became a migration site, annotated on the comment line, and mid-window it handed the runner's ImageOS to a label the repo never uses. Also from the review: the first-run step summary no longer says tools were locked in a file that --no-update-lock stopped it writing, runGuard defaults to writing the lock for callers who never saw the flag main() resolves, and the "Explained by the scheduled migration" line now has its explains key in --json without --fail-on-migration, which is where the README already said it was free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scoping the matrix scan to block-style matrix: keys dropped
matrix: {os: [ubuntu-latest, ubuntu-22.04]} on the floor, which took the labels
out of every extractor. The matrix: line is now scanned itself and still opens
the block, since the mapping can run on.
Also from the review: guard --json with no ImageVersion printed annotations and
no document when only the retirement lane had run; a manifest bullet named
constructor resolved to Object.prototype instead of being read as a tool; and
stdout said "baseline recorded" where --no-update-lock had just stopped it
recording anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scoping the scan to matrix: blocks fixed comments and shell lines by dropping everything else with them: workflow_call input defaults and env: values are where plenty of real workflows keep the label. It walks the file again, skipping comments, block scalars and the keys that hold prose or shell rather than a value. Three more from the review. jobKeys latched onto the first key called jobs at any depth, so an input named jobs voided every job id in the file and with it all attribution: the jobs map is a top-level key. A matrix that lists both ubuntu-latest and ubuntu-24.04 no longer reads a 24.04 runner as the floating label running late, since the same matrix asks for that image by name. And a run from a file the scan never saw, which is what a reusable workflow reports, falls back to matching on the job id alone. guard also scans the workflow directory once per run now instead of up to five times for the same answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five things the sixth review round found, all in the migration lane's edges. A trailing comment on a runs-on: line was part of the label, so `runs-on: ubuntu-latest # floating on purpose` was not a floating site at all. Both scanners strip it now. The drift step summary claimed the lock file was updated under --no-update-lock. It says what actually happened. The line that credits the migration for the drift matched on label and job id, so a matrix leg naming the new image itself read as GitHub moving you. It now applies the same rival-leg rule the annotations do. The migration lane diffed the tools the workflows mention even when the lock file listed others; the retirement lane has always preferred the lock. The lock is read once at the top of runGuard and both lanes use it. Tests for all four, plus the scan is now memoised so a guard run with both lanes on reads .github/workflows once.
The seventh review round found the lock read I hoisted last commit now runs before the "not a GitHub-hosted runner" skip, so a lock this version cannot parse turned a clean skip into exit 2 and stopped the retirement and migration lanes from running at all. The read is lazy again: the migration lane takes what it can get, the drift lane is the one that cannot work without the file and is still where the error surfaces. jobKeys kept the raw YAML key, so `"build":` never matched the bare GITHUB_JOB Actions exports. Quoted ids are unquoted, like labels. explainDrift and attributeImageOS had grown into the same job rule written twice: scope the sites to the running job, is there a direct one, is there a matrix leg naming the runner's own label instead. That rule is now labelOwnership in detect.mjs and both callers read it. Behaviour is unchanged; the two differ in what they do with the answer, which is all that is left in each. Also moves labelColumn's doc comment back onto labelColumn.
Both regexes added on this branch let an indent run and the text after it claim the same spaces with a mandatory `:` between them, which is the js/polynomial-redos shape this repo fixed in 1.2.0 and wrote a linearity test for. 16k spaces on one line cost 81ms in the matrix scan and 332ms in the manifest header, so a long enough line hangs the scan. Both keys now have to start with a non-space, and both inputs join the existing pathological-input cases. The block-scalar skip measured the indent before the `- `, so a step written `- run: |` swallowed its own env: and with: keys. It measures the key's own column now.
…th lanes The explanation of drift caused by an announced move was in stdout and in --json but not in the step summary, which is where most people read a guard run. It sits above the drift table now. runsOnTargetsFor walked .github/workflows itself, so guard on a self-hosted runner scanned twice. It takes the memoised scan, and runners builds one of its own. The two lanes that compare two labels' manifests had the same resolve/split/diff block written twice. diffManifestTools in manifest.mjs is that block, and plan and the migration lane share it. The missing-on-both rule now changes in one place. Changelog: the plan transcript was missing the deprecation-deadline line the command always prints, and the tools bullet named the retirement lane where it meant the drift lane.
`[^\r\n]*$` cannot reach the anchor on a line that still holds a bare CR, so every ` #` on it rescans to the CR: 200k characters of ` #` took 10.1s through the matrix scan, which runs stripComment on every line of a file with an expression runs-on:. Same fix readScalar already uses, and the input joins the linearity test that should have caught it.
--json on a drift run now carries the same `written` boolean the baseline payload has, so a reader can tell a report from a recorded run without inspecting the lock file. An empty --tools list (`--tools ,`) counted as an explicit list in the migration lane and skipped the fallback the drift lane does; both check the length now. README's phase table was missing the row for a runner already on the new image before the window opens, which the code reports as a notice.
`plan --from ubuntu-latest` during the rollout reaches the same sentence guard does, and it said "which of the two this job ran on is not known here" on a command that runs on no job at all. It now says what is true in both places: the label means either image until the window closes, and which one a job gets depends on the runner it lands on.
It passed --as-of in runGuard's opts, which only main() reads, so the test ran against the real clock and fetched the two manifests for real. It takes deps.now and a fixture loader, like every other timed guard test. Also puts the scan docstring back over scanner().
guard --json printed nothing at all on the unknown-runner-label branch, the same hole this branch already fixed next door for the self-hosted skip. Both branches now build the document from one helper, so a run that never reaches the drift diff still emits whatever the lanes found. analyseWorkflow was calling scanRunsOn three times and jobKeys and the matrix scan twice per file to produce two halves of the same walk. It walks once and splits, with the matrix and job passes lazy for the callers that do not want them. detect() output over every fixture directory and the repo's own workflows is byte-identical before and after. Mid-window the migration lane and the drift diff both want the old image's manifest, which is a 300KB read done twice. One loader per guard run, keyed on label and ref. README: the lane reads the workflow files, so it needs the checkout.
Three attribution bugs in the migration lane, all about whose runner this is. A job inside a reusable workflow reports the caller in GITHUB_WORKFLOW_REF while GITHUB_JOB is the id in the callee. The matcher trusted the file name whenever the caller was a file it had scanned, which is exactly the reusable case, so the real runs-on: line was rejected and a valid ImageOS thrown away. The job id decides now; the file only breaks a tie. With no GITHUB_JOB at all, the image was attributed whenever it was one of the window's two labels, and only a matrix leg could outrank that. A sibling job with a plain runs-on: ubuntu-26.04 explains the same image just as well, so a repo with a compat job reported ubuntu-latest as migrated two weeks early. Inside one job the only rival is still a matrix leg; unscoped, every site is. The note on that path asserted "this check did not run on ubuntu-latest", which is the one thing nothing in that case knows. It now names the missing GITHUB_JOB. Also: the manifest memo key had an escaped $, so the ref never reached the key. No caller passes one yet, which is why the suite stayed green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hostile ImageOS A prose key with no block marker still folds over the lines below it, so a step written as `- name:` with the title on the next line put every label-shaped word in that title back into the scan. Under `if:` the same shape could turn --fail-on-retirement into an ::error on a line nobody can act on. Prose keys now own their indented block whether or not someone typed the `|`. The jobs map ended for `current` but not for the walk, so the first key at the job indent under a later top-level block was adopted as a job id. `x-templates:` with a `build:` under it invents the same id a real job has, and a job id is what ties GITHUB_JOB to a runs-on: line. ImageOS is an environment variable and the ImageOS -> label table was indexed with it directly, so `__proto__` came back truthy and the lane reported an image nobody announced. One labelForImageOS() with an own-key check now serves all three call sites. manifest.mjs already did this; the lane did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--fail-on-migration failed the build on any anomaly whatever the threshold, and "still serving the old image after the window" is an anomaly. GitHub ran both previous latest moves late. On the day it slips again, every repo with the input set goes red over a runner that works, with no value of the threshold that turns it off. It stays an ::error annotation and a reported anomaly, because the announced date has passed, but it no longer decides the exit code. An image that is neither end of the window still fails at any threshold: that one is the label meaning something nobody announced. --as-of forced UTC for a bare date only, so a time with no zone went through Date.parse in local time while every countdown counts UTC days. On this box (UTC+8) --as-of 2026-10-19 reports the window open and 2026-10-19T00:00:00 reports it a day away. Same calendar day, different annotation. And the same own-key check labelForImageOS just got, for the three tables keyed by a label that came out of a workflow file or argv. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The memo kept the promise, rejection and all, so the second lane to ask for the same manifest was handed the first lane's 502 with no request of its own. The migration lane degrades to a note when a manifest is unreachable; the drift lane cannot, and a null manifest there reads as every locked tool having been removed. Only a read that worked is kept now. migrationStatus still indexed MIGRATIONS directly, so migrationStatus and surveyMigration answered '__proto__' with a window made of undefined. Both are public exports, even though the CLI filters with migrationFor first. And the phase table in the README now says which rows fail the job, since the threshold decides only the countdown before the window opens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without GITHUB_JOB, `direct` says only that some job in the repo asks for ubuntu-latest, and the runner in front of you might be the sibling job someone pinned to ubuntu-26.04 by hand. attributeImageOS gates the same signal with `here && direct`; explainDrift did not, so it credited GitHub for an upgrade the repo did itself. New fixture with both jobs covers all three cases: no job id, the floating job, the pinned one. Also the countdown suffix: "(1 days)" on the last day before every date this tool prints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three copies of the own-key check had grown (labels, manifest headers, and the one I missed: the tool tables). src/tables.mjs holds it now, and tools.mjs uses it, which fixes `--tools constructor` crashing the manifest resolver with "candidates is not iterable" instead of treating it as a tool nobody has. Also finishes the pluralisation: the annotation title still read "becomes ubuntu-26.04 in 1 days", and so did the retirement lane's "1 days left". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ownership rule drops the file check when no site in the scan it was given sits in the file this run came from. The migration lane gave it the pinned sites plus one floating label's, so a run from a job named `build` in release.yml, with another `build` in ci.yml, lost the file check and claimed the other job's label. On windows-latest that reads as ubuntu-latest serving an image from outside the window: ::error, exit 1, nothing actually wrong. `others` is now every other site in the scan, which is what the rule's own docstring assumed. explainDrift already passed the union. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 1.4.0 hardening converted the tables it went looking for and missed the ones reached from the scanner, the prober and the retirement lane. `run: constructor build .` (conda ships that CLI) became a detected tool, and `isProbeable` then handed `probeTool` the Object constructor instead of a list of recipes: "recipes is not iterable", exit 2, report-this-bug. `uses: constructor@v1` put the function itself into the tool set. And `runs-on: constructor` with --fail-on-retirement produced a status with no migrateTo, so the annotation crashed on join. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…type `'constructor' in lockedMap` is true on a plain object, so a lock file with a tool of that name handed diffTool a function: "(to ?? []).filter is not a function", exit 2. The attribution map was the next crash, and `__proto__` was quietly dropped on the way in rather than compared. The maps the diff and the attribution are built from are Object.create(null) now, and the two that arrive as arguments to an exported function are read through lookup(). Also documents the runGuard --update-lock default, which this branch changed on the drift path without saying so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With a lock on ubuntu-24.04 and a runner on ubuntu-26.04, guard searched the
26.04 commit list for a 24.04 image version, missed, and stamped every moved
tool with the nearest 26.04 commit instead. The header printed a 24.04 version
under the 26.04 label. explainDrift right below already knew the label moved.
A run whose label moved now names both labels and attributes nothing.
The guard suite also claimed to be offline and was not: one test held a lock
version the env did not, which reached GitHub for attribution and, once rate
limited, put `${{ github.token }}` in front of the --json document that a test
was slicing at the first brace. Both fixed, and the brace slice is now one
helper that finds the line the document opens on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inside a reusable workflow the run reports the caller in GITHUB_WORKFLOW_REF and the job id lives in the callee, so the file drops out of the match. Only a matrix leg counted as a rival after that, and a plain `runs-on: ubuntu-26.04` under the same job id in another file could not veto attribution: the survey reported ubuntu-latest MIGRATED off a runner that may have been the pinned job. jobMatcher now goes through jobScope, which also says whether the id placed the run in one file. Unplaced, the rival rule is the unscoped one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
attribute() and the readme fallback shared one try block, so a 403 on the commit lookup skipped the manifest read as well. Every manifest-only tool then had no observed version, the diff called each one REMOVED, and --fail-on major turned a rate limit into a red build. The fallback is its own read now. Also corrects the README's drift-explanation example, which still showed a same-label header and a commit link. A run whose label moved prints both labels and attributes nothing, as of the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nt one day as one A workflow_call input default or a top-level env value sits above the jobs map, so the site carried no job id and a scoped run threw away a migrated runner's ImageOS. Attribute such a label to every job whose runs-on: is an expression. Wire plural() through the retirement and self-hosted lanes, which still said "1 days" in the annotation titles, the summary table and the failure line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not the phase The prose-key skip added in 1.4.0 swallowed a workflow_call input named "name", losing its default and every label the file asked for. An empty prose key now only owns a body that is not itself a map. The step-summary badge was keyed by phase, so a stale runner read as settled next to its own ::error. Key it by state and head the column Status. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…b id placed is false for any run the scan cannot pin to a file, which includes a run with no GITHUB_WORKFLOW_REF and any run inside a reusable workflow, so a single matrix job was explained as a namesake in another file. Require the rival to be a plain runs-on: before naming it one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…keep --as-of in UTC The expression fan-out emitted a site per job, so three jobs sharing an input default meant three identical annotations on one line. Carry the ids on the site as jobs instead. A matrix axis called name, run or if was skipped as prose, losing every label under it. The prose keys only hold prose outside a matrix. --as-of fell through to Date.parse for anything non-ISO and read it in local time, the off-by-a-day it exists to prevent. Refuse it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A job whose runs-on: is a plain label is scheduled by that label, so an env: value under it that happens to look like an image is not a runner it asks for. It was becoming a rival matrix leg, which threw away the runner's own ImageOS and failed --fail-on-migration on a runner that had already migrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… as one A job with no runs-on: of its own is a reusable-workflow call; the label it hands the callee in with: is still a runner this file asks for, and the new token-scan filter was dropping it. The matrix wording was also reaching a run whose own job pins the floating label, where both halves of the sentence are false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An unplaceable run matched on the job id alone, so a plain runs-on: ubuntu-latest in another file attributed a Windows runner's image to the floating label and raised an unrecognised-image error on a workflow the run never touched. Require every file holding a job of that id to ask for the label before a direct site settles it. Also: with no GITHUB_JOB, a matrix leg was described as a workflow asking for the image by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
guard returned EXIT_USAGE before reportRetirement and reportMigration ran, so a repo with no detectable tools got the annotation without the reason line and exit 2 instead of exit 1. Also stop imageDiffs calling a header field the other OS does not publish a removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
guardAcrossTheMove locked Node.js at 22.23.2 and every assertion under it depended on the machine disagreeing. setup-node's node-version: '22' now resolves to exactly 22.23.2, so on all three CI legs the probe matched the lock, there was no drift, and the seven tests about how drift is attributed passed or failed for reasons that had nothing to do with attribution. Derive it from process.versions.node one major down, the way the PATCH-only test next door already derives its version. The probe reports the interpreter running the test and engines floors this package at 22, so the two can never meet again. Reproduced locally by pinning LOCKED_NODE to process.versions.node: the same seven tests fail, 395/402, which is what CI printed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
ubuntu-latestmigrates from Ubuntu 24.04 to Ubuntu 26.04 in a gradual rollout between 2026-10-19 and 2026-11-19 (actions/runner-images#14748). For that month the label is two different operating systems depending on which runner a job lands on, and no workflow file changes. The kernel goes6.17.0-1022-azureto7.0.0-1012-azureand systemd255.4-1ubuntu8.17to259.5-0ubuntu3.4.This branch adds the lane that shows you that before it lands.
MIGRATIONSinsrc/labels.mjs: floating label, both concrete ends, the window, the source issue. BesideDEADLINES, not inside it, and sharing no keys with it. A retirement is an end date for one image; a migration is a dated window between two live ones.plan --from <floating label>resolves both ends and diffs the images instead of refusing. A floating--to, or a floating--fromwith no announced migration, is still refused.plannow prints OS, kernel and systemd rows for every comparison. They were parsed already and thrown away.guard --fail-on-migration <days>and the matching action input, off by default. Without itguarddoes not fetch the two manifests at all.--as-of <date>on every command that counts down to something, so the countdowns are testable against a frozen clock.ImageOSis read as evidence about a floating label only when the job exporting it asked for that label.GITHUB_JOBandGITHUB_WORKFLOW_REFsay which job; the scan already knows whichruns-on:belongs to which job. A lint step pinned toubuntu-22.04in a repo that usesubuntu-latestelsewhere reports the window from the calendar and says why, rather than announcing thatubuntu-latesthas gone somewhere unrecognised.A rollout running past its announced end is annotated
::errorand reported, but does not fail the build. GitHub ran both previouslatestmoves late, and a failure no threshold turns off is one people delete 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.All four output formats carry it: annotations, a step-summary table, a
migrationkey in--json, and the text report. Existing contents are untouched.Also fixed here
Nine review rounds ran over the branch. The ones that matter:
REMOVEDin the same run, so a rate limit or anECONNRESETred a build under--fail-on majorand dropped the tool from the lock so the next run called itADDED. Unobserved tools now fall out of the diff, keep the lock entry they had, and are named in--jsonundernotCompared. A tool that has a probe recipe still reports as removed: the probe looked for it on the machine and did not find it. Same treatment for a tool with no recipe that the readme does not list, since the readme is a curated list under headings that get renamed.run-name: nightly build on ubuntu-22.04and an input'sdescription:both produced a retirement::erroron a line nobody can act on. A job literally calledmatrixturned the prose filter off for its whole body; onlystrategy.matrixis a matrix now.labelOwnershipfield nothing read, and animageDiffsdocstring that claimed macOS manifests carry no kernel line while the macOS-15 fixture publishes one.Every fix has a test proven to fail when only the source change is reverted.
Verification
Same 402 pass offline with
globalThis.fetchreplaced by a throw, so nothing in the suite is quietly reaching the network.npm run lintclean.npm packunpacked to a clean path outside the repo and driven end to end against the live manifests:plan --from ubuntu-latestprints the window and the deltas,guard --fail-on-migration 30withImageOS=ubuntu24mid-window annotates theruns-on:line and exits 1, the same run withImageOS=ubuntu26exits 0.Clone check before opening this: difflib over the line lists of every function in
src/, no pair above 50%. The migration lane shares the manifest cache, the scan and the report writers with the retirement lane rather than paralleling them. Where I stopped:scanRunsOnstill parses only the scalar form ofruns-on:, so the mapping form (group:pluslabels:) yields labels likelabels: [ubuntu-22.04]and a retiring image written that way is never flagged. That is identical onmain, so it is a gap from 1.0.0 rather than a regression, and widening it inside the release that already rewrote that file is how regressions ship. It is first in line for 1.4.1.Not covered
windows-latestandmacos-latestmigrations are a data addition once GitHub announces them. TheMIGRATIONStable is a transcription with no feed behind it, because no machine-readable feed exists; if the dates move, the lane is wrong until someone edits and releases.