Skip to content

Flutter's main-channel version names don't sort, breaking our flutter version bound #2422

Description

@chrisbobbe

(I wrote this issue text using a Claude Code on the Web session, using Opus 5. I then had Claude Fable 5 review it in a local Claude session; the agent there said it verified the claims against "the repo, the Flutter tree, pub_semver 2.2.0, and GitHub".)


Our flutter version bound in pubspec.yaml intermittently rejects Flutter versions that are newer than the bound, breaking CI. This has now bitten us three times over about a year, each time handled with a different ad-hoc workaround. Let's fix it properly.

Symptom

Most recently, on #2419, CI fails at flutter pub get:

The current Flutter SDK version is 3.47.0-1.0.pre-506.

Because zulip requires Flutter SDK version >=3.47.0-1.0.pre-91,
version solving failed.

pre-506 is 415 commits newer than pre-91.

Cause

This isn't a bug in pub — it's semver-correct behavior, triggered by a change in how Flutter names main-channel versions.

In 3.47.0-1.0.pre-91, the pre-release part is 1.0.pre-91, which splits on . into the identifiers 1, 0, pre-91. Per semver, an identifier that's all digits compares numerically, but one containing letters or hyphens compares as an ASCII string. So pre-91 is a single alphanumeric identifier, and pre-506 sorts before it: 5 < 9, and the comparison stops there, never reaching the fact that one number has three digits and the other two.

Before flutter/flutter#172141 (2025-07) the names looked like 3.33.0-1.0.pre.832 — with a dot, so the commit count was its own all-digits identifier and compared numerically, correctly. We first took the new format in 55d055f (2025-07-24).

Confirmed against pub_semver 2.2.0:

3.47.0-1.0.pre-506  vs  3.47.0-1.0.pre-91    ->  <     # the failure
3.33.0-1.0.pre.832  vs  3.33.0-1.0.pre.1085  ->  <     # old format, correct
3.47.0-1.0.pre-506  vs  3.47.0-1.0.pre-100   ->  >     # fine, same digit count
'>=3.47.0-1.0.pre-91'.allows(3.48.0-1.0.pre-3)  ->  true

The last line is why this only bites within a release cycle: across a branch cut the base version differs and compares numerically.

For reference, the N in pre-N is the commit count from git describe — commits on main since the last release tag. It resets to 0 at each branch cut and then climbs, with no fixed width. That's the whole problem.

What we've done about it so far

Why the existing workarounds don't apply now

Bumping the bound isn't available on #2419: that upgrade deliberately stops at pre-91 because pre-92 breaks the iOS notification service extension (flutter/flutter#190909, still open — notifications show placeholder text). Any bound at pre-100 or above means taking that commit.

Also, the "starts with 1" rule from 7cab2ad isn't actually sufficient. Since the comparison is an ASCII string compare, a bound C is safe against every numerically-greater N only if str(C) <= str(N) for all of them — which holds just for powers of ten (pre-10, pre-100, pre-1000). A bound of pre-175 breaks as soon as the counter reaches 1000, because "1000" < "175". That didn't happen in the 3.37 cycle, but the 3.33 cycle did reach pre-1285 (2453ae7).

Re-pinning CI to the pubspec.yaml commit would work, but it gives up what we deliberately regained in 57db606 five months ago: CI running against latest main, so upstream breakage shows up here early.

Proposed fix

Stop asking pub to express a constraint it can't, and check the floor ourselves — by commit ancestry rather than by version name, since pubspec.yaml already records the commit sha alongside the bound.

  1. Loosen the bound to the branch-cut prefix, keeping the tested commit in the comment. By semver's prefix rule "pre" < "pre-N" for every N, so this admits every pre-release in the cycle and everything later:

    environment:
      # (…existing comment…)
      #
      # The `flutter` bound below is deliberately loose: Flutter's main-channel
      # version names don't sort correctly (flutter/flutter#172141 made the
      # commit count part of an alphanumeric pre-release identifier, so `pre-506`
      # sorts before `pre-91`), so a precise bound rejects newer Flutters.
      # The real floor is checked by `tools/check flutter_version`.
      # TODO(#1851) restore a precise bound if upstream makes these sortable
      sdk: '>=3.14.0-29.0.dev <4.0.0'
      flutter: '>=3.47.0-1.0.pre'  # f65eec98830b2a4c0c7d5d0b13e3e914b4fb00f7

    The comment has to sit above the sdk: line, as shown: pubspec_flutter_version in tools/lib/deps.sh matches the flutter: line only immediately after the sdk: line. With that placement it parses the loosened bound unchanged.

  2. Add the real floor check to run_flutter_version in tools/check, above the files_check pubspec.yaml gate — the floor is about the developer's environment, not about the diff, so it should run whatever you changed. The pubspec_flutter_version call and the flutter_git setup currently sit below the gate, so they move up along with it:

        local status=0
        "${flutter_git[@]}" merge-base --is-ancestor "${flutter_commit}" @ \
            || status=$?
        case "${status}" in
            0) ;;
            1)  # error: your Flutter is older than pubspec.yaml requires;
                # suggest `flutter upgrade`
                return 1;;
            *)  # error: commit not found in the Flutter tree;
                # suggest `git -C "${flutter_tree}" fetch origin`
                return 1;;
        esac

    The three exit codes split cleanly: 0 = floor satisfied, 1 = Flutter too old, 128 = commit missing from the tree. Worth keeping 128 distinct: on a --filter=blob:none clone, git attempts to fetch the missing commit from the remote before failing, so that path makes a network round-trip.

  3. Report upstream to Flutter that main-channel version names haven't been orderable since #172141, and cross-link from Update the flutter_version check to follow upstream #1851. A quick search turned up no existing issue for it. That's the only fix that would let us restore a precise bound.

Notes and caveats

  • In CI this check is trivially satisfied — the tree is a fresh clone of latest main. Its value is local: catching a contributor whose Flutter is stale. That's exactly the role the pub bound used to play.
  • It's mildly wrong for someone on their own Flutter branch (testing an upstream PR, say): their Flutter can be newer in every way that matters while the pubspec commit isn't literally an ancestor. The error message should point at --exclude flutter_version.
  • This doesn't replace pub's handling of bounds that third-party packages declare on Flutter. We're only taking over our own self-imposed floor.

Metadata

Metadata

Labels

a-toolsOur own development tooling, scripts, and infrastructure

Type

No type

Projects

Status
No status

Relationships

None yet

Development

No branches or pull requests

Issue actions