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:
- A team hits a bug and reports it.
- We fix it, and the fix lands in a newer version.
- That newer version also contains weeks of unrelated changes, one of which is a new regression for the team.
- 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
maintriggers a release that is published as the latest version.make_latestis 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>.xbranches, acli-backport.ymlworkflow 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>.xprotected branches andcli-backport.ymlare 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_lateststable.
New work, in rough order:
- Canary prerelease stamping: change
main’s version derivation to emitX.Y.0-canary.Nwithmake_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. - RC step: a workflow (or a mode of the line cut) that publishes
X.Y.0-rc.Nfrom the release branch during soak. Mechanically this is the same publish path as a backport. - Promotion action: a manually dispatched workflow that tags
X.Y.0and setsmake_latest=true. - 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
mainhas 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?