Skip to content

1.4.0: the ubuntu-latest migration lane - #11

Merged
Booyaka101 merged 44 commits into
mainfrom
floating-label-migrations
Sep 20, 2026
Merged

Booyaka101 merged 44 commits into
mainfrom
floating-label-migrations

Conversation

@Booyaka101

Copy link
Copy Markdown
Owner

What this is

ubuntu-latest migrates 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 goes 6.17.0-1022-azure to 7.0.0-1012-azure and systemd 255.4-1ubuntu8.17 to 259.5-0ubuntu3.4.

This branch adds the lane that shows you that before it lands.

  • MIGRATIONS in src/labels.mjs: floating label, both concrete ends, the window, the source issue. Beside DEADLINES, 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 --from with no announced migration, is still refused.
  • plan now 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 it guard does 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.

ImageOS is read as evidence about a floating label only when the job exporting it asked for that label. GITHUB_JOB and GITHUB_WORKFLOW_REF say which job; the scan already knows which runs-on: belongs to which job. A lint step pinned to ubuntu-22.04 in a repo that uses ubuntu-latest elsewhere reports the window from the calendar and says why, rather than announcing that ubuntu-latest has gone somewhere unrecognised.

A rollout running past its announced end is annotated ::error and reported, but does not fail the build. GitHub ran both previous latest moves 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 migration key in --json, and the text report. Existing contents are untouched.

Also fixed here

Nine review rounds ran over the branch. The ones that matter:

  • Unobserved is not removed. With both manifest reads failing, every manifest-only locked tool was warned as skipped and then reported REMOVED in the same run, so a rate limit or an ECONNRESET red a build under --fail-on major and dropped the tool from the lock so the next run called it ADDED. Unobserved tools now fall out of the diff, keep the lock entry they had, and are named in --json under notCompared. 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.
  • Prose read as runners. run-name: nightly build on ubuntu-22.04 and an input's description: both produced a retirement ::error on a line nobody can act on. A job literally called matrix turned the prose filter off for its whole body; only strategy.matrix is a matrix now.
  • Two missing-directory notices for one missing directory, a brownout countdown that said "184 until", a labelOwnership field nothing read, and an imageDiffs docstring 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

# tests 402
# pass 402
# fail 0

Same 402 pass offline with globalThis.fetch replaced by a throw, so nothing in the suite is quietly reaching the network. npm run lint clean. npm pack unpacked to a clean path outside the repo and driven end to end against the live manifests: plan --from ubuntu-latest prints the window and the deltas, guard --fail-on-migration 30 with ImageOS=ubuntu24 mid-window annotates the runs-on: line and exits 1, the same run with ImageOS=ubuntu26 exits 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: scanRunsOn still parses only the scalar form of runs-on:, so the mapping form (group: plus labels:) yields labels like labels: [ubuntu-22.04] and a retiring image written that way is never flagged. That is identical on main, 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-latest and macos-latest migrations are a data addition once GitHub announces them. The MIGRATIONS table 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.

Booyaka101 and others added 30 commits September 20, 2026 10:13
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>
Booyaka101 and others added 14 commits September 20, 2026 18:04
…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.
@Booyaka101
Booyaka101 merged commit 41a13f2 into main Sep 20, 2026
11 checks passed
@Booyaka101
Booyaka101 deleted the floating-label-migrations branch September 20, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant