Versioning

Keeping the module, the native hosts, and the framework in step as they release.

This page is an early draft. The shape of the guidance is settled, but the examples and the framework-specific tooling notes will be expanded.

An add-to-app project has several parts that can release at their own pace: the Flutter module, the native host apps, the framework (inlay) the module is built on, and - underneath all of them - the Flutter SDK itself. Versioning comes down to deciding which of those parts are allowed to move independently and which have to move together, and to making the points where they meet explicit rather than accidental.

How much of this you have to think about depends directly on the build integration mode. With source-level integration most of it disappears: a single checkout pins every part at once, and the commit is the version. With prebuilt-artifact integration, a published version number becomes the contract between the module and each host, and the rest of this page is mostly about managing that contract.

What actually needs versioning

There are four moving parts, and they do not all carry the same risk.

  • The Flutter SDK. This is the single most important thing to pin. A drifting SDK version between developer machines, the module's CI, and the native build agents produces non-reproducible builds and the worst kind of "works on my machine" bug. Pin it explicitly - in CI config and in a checked-in file the team agrees on (an fvm .fvmrc, a mise/asdf .tool-versions entry, or at minimum a documented version) - and treat a bump as a deliberate, reviewed change.
  • The framework (inlay). The module depends on inlay; the generated boundary is produced by inlay_gen. These two must agree, and a host that links a prebuilt module inherits whatever inlay version that module was built against. Bump them together.
  • The Flutter module. When consumed as an artifact, this is the version a host pins to. When consumed from source, it has no version number of its own - the commit is the version.
  • The boundary contract. The Pigeon-generated platform-channel APIs and the inlay_gen-generated routes/stores are the actual contract between Dart and native - the shape of every message that crosses the channel inside the app process. Where a mismatch shows up depends on how the generated native code gets to the host. When the host compiles against Kotlin/Swift generated from the same schema as the Dart side (which is the case when that code ships inside the module, as in Inlay's example, where a companion plugin holds it so that flutter build aar bundles it, or when both sides live in one checkout), a breaking change is a compile error on the native side. When the host keeps its own copy of the generated code, or links a prebuilt module generated from a different schema revision, the mismatch only shows up as a runtime failure, and that is the case that deserves the most care. See Native-Flutter communication.

The boundary contract is the one people tend to forget, because it has no version number you type anywhere - it is implied by the module version. In practice the module version carries the boundary contract with it: if you bump the module without regenerating and re-linking both native sides, you can end up shipping a host that calls a method signature the module no longer has.

Inlay makes the boundary-contract version explicit - and checks it for you. inlay_gen emits a schema fingerprint into the Dart, Kotlin, and Swift output; every route carries it across the boundary and the generated decoders verify it on arrival. A drifted host/module pair fails on the first navigation with a descriptive error naming both fingerprints, instead of corrupting data in production. No app-side setup is needed.

Lockstep vs divergence

The central decision is whether the module and the hosts are allowed to release independently.

The module and the native hosts move together: a given app build embeds a known module version, and you do not ship a host without re-validating it against the module it embeds. In a monorepo with source integration this comes for free - there is one version, the checkout. It is the safest model because the boundary contract is validated by construction: the code that calls the boundary and the code that implements it are built from the same source at the same time.

Controlled divergence

The module releases on its own cadence and hosts adopt new versions when they are ready. This is what a polyrepo with artifact integration buys you, and it is sometimes genuinely necessary - two native teams with their own release trains cannot always bump in unison. The cost is that, at any moment, different hosts may embed different module versions, and you have to make sure each of those pairings actually works.

The risk is divergence with no bound on it - a host stuck three module versions back because no one owns the bump. Divergence should decouple release timing by days, or a sprint at most. If a host routinely lags far behind, it usually means either that the boundary is changing too fast or that no one owns the upgrade, and in most cases it is an argument for moving back toward a monorepo.

Pinning across the boundary

How a host pins the module depends on integration mode:

  • Source-level (monorepo): the checkout pins everything. There are no version numbers to manage, because both sides are always built from the same commit.
  • Source-level (submodules): the host pins the module to a commit SHA. Precise and reproducible, but not semantic - the pointer alone cannot tell you whether a version is newer or compatible, only that it is this exact commit. Updating means committing a new pointer.
  • Artifact (polyrepo): the host pins a semantic version by dependency coordinates - Maven coordinates on Android, a Pod/SPM version on iOS. This is the only mode where SemVer carries real meaning across the boundary, so it is the mode where you actually need versioning conventions.

Versioning the artifact

When you publish the module as an AAR / XCFramework, give it a SemVer and let the boundary contract decide what counts as a breaking change, because the boundary is what the host actually couples to:

  • Major - the boundary changed incompatibly: a channel method removed or its signature changed, a generated route or store field renamed or removed. A host cannot adopt this without code changes on the native side.
  • Minor - additive boundary changes: a new route, a new store key, a new method. Existing host code keeps working.
  • Patch - Dart-internal changes with no boundary impact: a bug fix or UI tweak inside a screen. The host can take it blind.

A practical rule: if regenerating Pigeon or inlay_gen output changes a signature the host references, it is at least a minor and probably a major. Tie the version bump to a diff of the generated boundary rather than to a feeling about how big the change was.

Versioning workflow by mode

In a monorepo with source integration, there is no publish-and-bump step. A boundary change is one atomic commit that updates the schema, regenerates all three platforms, and adapts both sides together. The version is the commit, and CI validates the whole thing at once. This is why source integration in a monorepo is the lowest-overhead model - versioning is mostly a non-problem there.

In a polyrepo with artifact integration, versioning is what holds the two sides together, and the hand-off is a sequence:

  1. Change the module's Dart and regenerate the boundary.
  2. Validate the Flutter side in isolation (dart analyze, dart test, regeneration check).
  3. Build and publish the artifacts (every flavor and build mode the hosts need - see Build integration) under a new SemVer.
  4. Bump each host to the new version and adapt native code to any contract change.
  5. Validate each host against the version it now embeds.

This should be automated - publishing a version and opening the host-bump PRs is a mechanical hand-off, and when it is done by hand it tends to stop happening reliably after a few weeks. The window between step 3 and step 5, where the published module is ahead of the hosts, is the divergence you are deliberately accepting; keep it short.

Match build modes when pinning artifacts. A version number does not capture build mode - a release host must link the release build of that version, and debug/profile/release artifacts cannot be substituted for one another. Many "version mismatch" failures turn out to be mode mismatches.

Pinning the Flutter SDK and the framework

These two underlie everything above and are worth calling out separately.

The Flutter SDK should be pinned the same way you pin Xcode and the Android SDK. With source integration the native build agents run Flutter themselves, so the pin has to live in native CI too; with artifact integration only the module pipeline needs it, which is one of the reasons artifacts decouple native teams. Either way, the module's CI and every developer's machine should agree on one version, enforced by a checked-in config rather than by convention.

inlay and inlay_gen should stay on the same version and be bumped as a unit. A module built against one inlay version and consumed by a host is fine - the host links whatever the module shipped - but a module whose inlay and inlay_gen disagree can produce a boundary that does not match the runtime. Treat a framework bump like an SDK bump: deliberate, reviewed, and validated across the boundary before it ships.

Rules of thumb

  • Pin the Flutter SDK explicitly and identically everywhere. It costs little and prevents the worst reproducibility bugs.
  • Treat the module version as the boundary-contract version, and drive SemVer off a diff of the generated boundary.
  • Prefer lockstep. If you allow divergence, put a bound and an owner on it - a host lagging far behind usually means something is wrong.
  • Automate the publish-and-bump hand-off. Manual version bookkeeping is where polyrepo projects tend to break down.
  • Match build modes when linking artifacts. Many "version" bugs in artifact integration are really mode mismatches.

On this page