RFC: Release channels and a stable line teams can trust

Summary

This RFC proposes splitting the Tuist CLI release stream into three channels (canary, rc, and stable) so that the version teams install by default changes slowly, is feature-frozen, and receives backported fixes. Today every commit on main is published as the latest version, which means a fix for one team ships bundled with every unrelated change merged since their last upgrade. The result is that adopting a fix often introduces a new regression, and teams cannot keep Tuist up to date. The channel model below decouples shipping fixes from shipping features: features accumulate on canary, a stable line is cut on a fixed cadence after a soak window, and fixes are backported onto the stable line as patch releases that carry nothing else.

Motivation

The most common complaint from teams running Tuist at scale is that upgrades break. The pattern repeats:

  1. A team hits a bug and reports it.
  2. We fix it, and the fix lands in a newer version.
  3. That newer version also contains weeks of unrelated changes, one of which is a new regression for the team.
  4. The team rolls back to an old version they know works, and stops upgrading.

We have no release that is allowed to change slowly. “Latest” is defined as “the most recent per-commit build,” so there is no soaked, feature-frozen version to sit on. Every downstream symptom (rollbacks to versions many months old, “we cannot adopt latest,” CI health regressions on upgrade) traces back to this single property.

The goal of this RFC is to make the recommended version:

  • Actually stable: it has been through a soak window before it becomes the default.
  • Slow-moving: it does not change as new features merge.
  • Fixable in place: it receives backported fixes as patch releases that contain only the fix.

Current State

  • Every merge to main triggers a release that is published as the latest version. make_latest is set, and mise / Homebrew resolve it as the version a bare install receives.
  • We already have the building blocks for backporting: protected releases/<major>.<minor>.x branches, a cli-backport.yml workflow that cuts a patch on a line without advancing the minor, a release ruleset requiring review on those branches, and server-side logic that advertises the highest-semver CLI release as “latest” rather than the most recently published one.
  • What we are missing is the channel model on top of this machinery, and the rule that the latest per-commit build is not what teams install by default.

In other words, we built the backport mechanism before naming the release model that gives it a purpose. This RFC supplies that model.

Prior Art

Three established CLIs solve the same problem, and the differences are instructive for a team our size.

Rust: the train model

Rust ships three channels: nightly (cut every day from master), beta (the next stable, undergoing final testing, accepting only critical fixes), and stable. Every six weeks, on a fixed date, nightly is promoted to beta and beta is promoted to stable. Feature work lands on master behind feature flags, so it is visible only to people who opt into nightly. A fix never reaches stable without passing through a soak window first. Reference: How Rust is Made and “Nightly Rust”.

React Native: continuous minors with an RC and a support lifecycle

React Native publishes a new minor regularly, but each 0.X.0 is preceded by a series of release candidates (0.X.0-rc.N) tagged as next on npm. After a release ships, it moves through Active Support, then End-of-Change (regressions only), then Unsupported. Notably, the .0 of a new minor receives a burst of patches immediately after release to stabilize it, and only a small number of recent minors are supported at once. References: Versioning Policy, Support Policy.

Node.js: as few supported lines as possible

Node.js runs Current, then Active LTS, then Maintenance. The most relevant lesson is the direction they are moving: starting with Node 27 they are reducing the number of concurrent release lines, with the stated reason that “each additional line increases backporting complexity” and most organizations only ever adopt the long-term-support versions anyway. Reference: Evolving the Node.js Release Schedule.

The takeaway for a small team: adopt the train shape (soak before promotion, features isolated from stable), but keep the number of supported lines to the minimum, because backport cost scales with the number of lines we maintain.

Proposed Solution

Channel overview

main ──(every commit)──▶  CANARY    4.200.0-canary.N   prerelease, never resolved as "latest"
        │
        ├──(cadence cut)─▶ RC        4.200.0-rc.N       feature-frozen, ~1 week soak, critical fixes only
        │
        └──(promote)─────▶ STABLE    4.200.0            make_latest, the recommended version
                                     4.200.1, 4.200.2   backported fixes land here as patches

Canary: per-commit prereleases

Every merge to main continues to build and publish, but the version carries a prerelease identifier: X.Y.0-canary.N (for example 4.200.0-canary.42, where N is the commit count on the line or the short SHA).

The prerelease identifier is the load-bearing change. Under SemVer, 4.200.0-canary.42 sorts below 4.200.0, so any tool resolving by semver maximum (mise’s tuist@4, a bare tuist@latest, Homebrew) will never land on a canary build. It will only ever see promoted stable releases. Setting make_latest=false alone is not sufficient, because it only affects the chronological “latest published” path, not the semver-maximum path. The prerelease tag is what makes canary invisible to the default install.

Canary exists for early adopters, for our own dogfooding, and for reproducing and fixing reported issues against the newest code. Installing it is an explicit opt-in to prereleases.

We use canary rather than nightly because the channel is per-commit, not nightly, and nightly would set a misleading expectation about cadence. The name also captures the role: canary users run ahead of everyone else and surface regressions first, so stable users never see them.

Release candidates

On a fixed cadence (proposed: every 2 weeks), we cut a releases/<major>.<minor>.x branch from main. This is the branch shape the backport machinery already expects. A tight cadence is deliberate: the smaller the delta between one stable line and the next, the smaller and safer each upgrade is, which is the direct antidote to “upgrades break.” The branch immediately enters the RC phase:

  • It is published as X.Y.0-rc.1.
  • It is feature-frozen. Only critical fixes and regressions are cherry-picked onto it. No new features.
  • It soaks for approximately one week. Tuist’s own development runs on canary, so the project is already ahead of the RC and is not its soak signal; the soak depends on teams opting into RC builds.
  • Each accepted fix produces X.Y.0-rc.2, and so on.

Like canary, RC versions carry a prerelease identifier and are never resolved as the default install.

Stable

When the soak window closes and the RC is clean, we promote it: tag X.Y.0, set make_latest=true. This is the single version we recommend in the documentation and surface in the dashboard. It does not move again as new features merge to main. That property, a recommended version that holds still, is the one thing teams need and the one thing we cannot offer today.

Following the React Native lesson that .0 of a new line is not yet fully trusted, we treat the first week after promotion as a deliberate stabilization burst: we actively backport any regression found in the field to X.Y.1 / X.Y.2, and we point “recommended” at the latest patch of the line rather than necessarily at .0.

Canary continues to advance on main the whole time, so feature work is never blocked by the stabilization of a line.

Versioning and distribution

No change to how versions are distributed, only to which ones are marked latest:

Version example Channel Resolved by
4.200.0-canary.42 canary explicit prerelease opt-in only
4.200.0-rc.1 rc explicit prerelease opt-in only
4.200.1 stable (current line) tuist@latest, bare install, tuist@4, [email protected]
4.199.6 stable (previous line) [email protected] (a pin to the older line)

This works with the existing mise (aqua:tuist/tuist) and Homebrew resolution, which already select by semver and already understand scoped patch lines.

Backport policy

We keep the number of supported lines to two:

Line Receives Mechanism
Current stable (X.Y.x) All fixes: bugs, regressions, security cli-backport.yml
Previous stable (X.(Y-1).x) Critical and security fixes only cli-backport.yml
Older No backported fixes n/a

A fix lands first on main (canary), then is cherry-picked via PR onto the current stable line, and onto the previous line only if it is critical or a security issue. CI never cherry-picks automatically; a human or agent opens the backport PR so conflicts are resolved deliberately. This is exactly the flow the backport workflow was built for.

Backport support is not server compatibility. A line dropping out of the backport window does not mean the server stops working with it. The server remains compatible with any CLI version released within the last 3 months, so a team that has not upgraded keeps running; it simply stops receiving new fixes on its line and must move to a supported line (a small step, given the 2-week cadence) to pick them up. Only past that 3-month compatibility window does a CLI version fall out of server support, with deprecation warnings ahead of time. These are two separate windows on purpose: we actively maintain two lines, but we keep the door open to older versions for a quarter so an un-upgraded team is never abruptly broken by a server-side change.

Two lines is a deliberate ceiling. Following the Node.js experience, every additional supported line multiplies the backport and verification cost, which is the cost a small team can least afford.

Support and cadence

  • A new stable line is cut every 2 weeks, with a 1-week soak, so at any moment one line is live as stable and one RC is soaking.
  • The current line is supported until the next line is promoted, then it becomes the previous line.
  • The previous line is supported (critical and security only) until the line after that is promoted.
  • We keep the support cap at two lines even at this cadence. A short cadence makes rolling forward to the current line cheap (the delta is small), so moving forward is the default path to a fix and deep backporting to old lines stays the exception. This is what lets the line count stay low while cutting often.
  • Server compatibility runs on a separate, longer window: the server stays compatible with any CLI released in the last 3 months, even though only two lines receive backported fixes. An un-upgraded team keeps working; it just does not get new fixes until it moves to a supported line.
  • End-of-life dates are published, so support is a documented policy rather than a per-request negotiation.

What teams should pin to

The recommendation to teams becomes simple and stops being a moving target:

  • Pin to a stable line, for example [email protected].
  • That line only ever receives backported fixes, never new features.
  • When you are ready to take on a batch of new features, move to the next stable line deliberately, with an RC available to test against first.

Non-Goals

  • This RFC does not propose changing the CLI’s public API, configuration, or behavior. It changes only how releases are cut, named, and promoted.
  • It does not propose long-term-support windows measured in many months. Two lines with a 4-week cadence is intentionally lighter than a full LTS program, sized to the team.
  • It does not change the server or cache release process. Those ship continuously by commit SHA and do not have the same install-surface problem.
  • It does not automate cherry-picking. Backports remain human-initiated PRs.

Trade-offs

Advantages

  • The recommended version stops changing as features merge, which is the core fix.
  • Fixes and features are decoupled: a team can take a fix as a patch that carries nothing else.
  • Early adopters and our own dogfooding still get per-commit builds on canary, so we keep fast feedback on new work.
  • It reuses backport machinery that already exists, so the net new work is small.
  • The recommendation to teams becomes a single, stable instruction instead of a case-by-case judgment.

Disadvantages

  • More moving parts in the release pipeline: three tag patterns instead of one, an RC soak step, and a promotion step.
  • Backporting is ongoing work. Even capped at two lines, it is a recurring cost that did not exist before.
  • A fix now reaches stable users more slowly than “merge and it is latest,” because it waits for a backport patch rather than riding the next per-commit canary build. This is the intended trade: slower, but without the regression.
  • Teams that currently pin exact per-commit versions will need to move to a line pin ([email protected]). This is a one-time migration in guidance.

Rollout

The work maps onto infrastructure that mostly exists:

Reuse as-is:

  • releases/<major>.<minor>.x protected branches and cli-backport.yml are the stable-line and backport mechanism.
  • The server already advertises the highest-semver CLI as latest; we point the recommended-version surfaces at the latest make_latest stable.

New work, in rough order:

  1. Canary prerelease stamping: change main’s version derivation to emit X.Y.0-canary.N with make_latest=false, and stamp it into the CLI version constant. This sits on top of the in-flight release redesign that already moves version stamping to tag-derived values.
  2. RC step: a workflow (or a mode of the line cut) that publishes X.Y.0-rc.N from the release branch during soak. Mechanically this is the same publish path as a backport.
  3. Promotion action: a manually dispatched workflow that tags X.Y.0 and sets make_latest=true.
  4. Documentation and dashboard: a clear “Recommended (stable)” versus “Canary” distinction, plus the support and end-of-life matrix.

The release redesign currently in flight (stop committing version pins to main, derive versions from tags) is a clean substrate for this: canary, rc, and stable become three tag patterns the same pipeline emits.

Open Questions

  • Cadence: is 2 weeks the right interval, or does the soak and promotion ceremony cost more attention than a 4-person team wants every fortnight? Should we allow skipping a cut when main has been quiet?
  • Soak length: Tuist’s own development and CI run on canary, so the project is always ahead of an RC and is not itself the soak signal for a line. Meaningful soak signal comes from teams opting into RC builds, which means a soak is only as good as its RC adoption. Is one week the right window, and how do we get enough teams running RCs for the soak to be worth anything? Should promotion depend on a level of RC adoption rather than on elapsed time alone?

References

I’m generally on board with it. A few thoughts:

  • The effectiveness of this depends on the RC adoption. What do you envision to invite & incentivize users to participate in testing those RC releases?
  • Is the second maintained line carrying its weight at a 2-week cadence? The buffer it gives is roughly one cadence cycle, which is the same thing as telling teams “you have 2 weeks to upgrade.” For a 4-person team, the recurring cost (cherry-pick triage on every fix, two CI matrices, two sets of docs) feels heavy for that. Worth considering one line + a clear upgrade window?
  • Do we need a formal backport mechanism, or can “ride the next train” be the default? At 2 weeks between cuts, most fixes can just land on main and ship naturally. Cherry-picking would only happen when a user is actually blocked on the current stable. That keeps the backport tax close to zero in normal weeks.
  • Should RC promotion be gated by time or by evidence? A 1-week soak with no external RC adoption is just “we waited a week.” If we can’t generate adoption, the gate doesn’t filter anything. Might be worth framing it as a floor (≥1 week) + signal (no regressions reported by N external users), so the gate is honest about what it’s measuring.

I believe the incentive will be those teams will get more stable CLI releases. In the end, we will just want them to trigger a CI run with the RC release, which should hopefully not be a big ask.

There are two support windows:

  • the 3-month window support of the server that ensures the CLI-server contract stays backwards compatible for this period
  • Our commitment of backporting fixes. I think this can be genuinely smaller to ensure the operation load is minimal. Right now, the window of backporting fixes to any version is 0, so this proposal is already a huge improvement.

Yes, we can be selective about which fixes we backport to the current stable, we don’t necessarily have to backport every fix.

I’d say it should be gated by time. I hope 1-week will be enough to get some teams to try it out. But this is something we can iterate on once we land on this new release approach.

Assuming an issue is found in 4.200.0, a patch release 4.200.1 is published, and a new feature lands on main, what version would be assigned to the next canary build?

  • 4.200.0-canary.43
  • 4.201.0-canary.1

If it’s the former, would mise with prerelease=true upgrade from 4.200.0-canary.42 to 4.200.1?

If it’s the latter, would mise with prerelease=true upgrade from 4.200.0-canary.42 to 4.201.0-canary.1?

Assuming the answer is yes in both cases, it seems that semver ordering alone does not prevent users from moving between stable and canary releases. Instead, canary releases would need to be versioned on a separate minor (or major) line, and the mechanism that actually keeps users on the stable channel would be prerelease=false.

Am I missing something?

You’re right, and the RFC wording is misleading here. Semver ordering is not the gate; the prerelease flag is.

In the intended model, once 4.200.0 is cut to a release line, main’s canary rolls to the next minor, so the next canary build is 4.201.0-canary.1, not 4.200.0-canary.43. Canary is always a prerelease of the next minor, so it sits above the current stable line rather than below it. Your scenario 1 (4.200.0-canary.43 sorting below a shipped 4.200.0) is exactly the broken case we avoid by rolling the minor forward at cut time.

What keeps the channels separate is that canary and RC are published as GitHub prereleases, and mise excludes prerelease-flagged versions from default resolution unless prerelease = true is set. So prerelease=false (the default) always resolves to the newest stable, and prerelease=true always resolves to the newest canary (since canary is always the highest minor). No crossing in either direction.

I’ll fix the “sorts below 4.200.0” line and add the branch/minor-allocation model to the Versioning section so this is unambiguous. Thanks for catching it.