Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pre-release version numbers #2222

Open
SimonSapin opened this issue Dec 17, 2015 · 46 comments
Open

Pre-release version numbers #2222

SimonSapin opened this issue Dec 17, 2015 · 46 comments
Labels
A-dependency-resolution Area: dependency resolution and the resolver A-semver Area: semver specifications, version matching, etc. C-bug Category: bug E-hard Experience: Hard S-needs-design Status: Needs someone to work further on the design for the feature or fix. NOT YET accepted.

Comments

@SimonSapin
Copy link
Contributor

Should Cargo do something special when a dependency has versions published with pre-release version numbers? Maybe not select them unless requested explicitly with cargo update --precise?

CC whitequark/rust-xdg#9, @whitequark, @alexcrichton

@whitequark
Copy link
Member

Specifically, if I put xdg = "2.0" or xdg = ">= 2.0" in Cargo.toml, Cargo selects 2.0.0-pre5, which is in clear contradiction of the semver spec, paragraph 11:

When major, minor, and patch are equal, a pre-release version has lower precedence than a normal version. Example: 1.0.0-alpha < 1.0.0.

@steveklabnik
Copy link
Member

Yes, Cargo should follow SemVer here.

I think that this might be the fault of the semver crate itself...

@SimonSapin
Copy link
Contributor Author

Ok, sorting should be fixed, but that’s separate from the original issue.

@whitequark
Copy link
Member

Well, given that * is not a valid dependency spec now, what is left in this issue?

@SimonSapin
Copy link
Contributor Author

Let me rephrase. Let’s say a package has three versions published: 1.0.0, 1.0.1, and 1.1.0-beta. If I depend on it with version requirement 1.0.0, Cargo will currently pick 1.1.0-beta since it’s the latest. But maybe "pre-release" should signal a version that is published for opt-in testing, but is not ready for general use? In that case, Cargo should default to ignore any pre-release version and pick 1.0.1 instead, unless explicitly requested.

Or, more generally, should we assign meaning (and tool behavior) other than the relative ordering to "pre-release"?

@whitequark
Copy link
Member

That is exactly what I am speaking about in #2222 (comment)

@SimonSapin
Copy link
Contributor Author

From the same paragraph that you quoted:

Precedence refers to how versions are compared to each other when ordered.

Precedence is not what I’m talking about.

This part of paragraph 9 is relevant to what I’m talking about, though:

A pre-release version indicates that the version is unstable and might not satisfy the intended compatibility requirements as denoted by its associated normal version.

Should Cargo consider 1.1.0-beta incompatible with 1.0.0 in the same way that 2.0.0 is incompatible with 1.0.0? (Whereas 1.1.0 is compatible with 1.0.0.)

@whitequark
Copy link
Member

As far as I'm aware, Cargo's notion of "compatible with x.y" is just >=x.y <x.(y+1), "compatible with x.y.z" is >=x.y.z <x.y.(z+1), and so on for any amount of parts, which is why precedence counts. The part of paragraph 9 you're talking about is illustrative and follows from the precedence rules.

@whitequark
Copy link
Member

I.e. since "compatible with 1.0" is >=1.0 <1.1 and 1.1.0-beta < 1.0, 1.1.0-beta will not be considered "compatible with 1.0".

@SimonSapin
Copy link
Contributor Author

I.e. since "compatible with 1.0" is >=1.0 <1.1

No. It’s >=1.0 <2.0

@SimonSapin
Copy link
Contributor Author

I.e. since "compatible with 1.0" is >=1.0 <1.1

No. It’s >=1.0 <2.0

Or rather something like >=1.0 <=1.9999.9999 with an infinity of nines, given how pre-releases sorts.

@whitequark
Copy link
Member

Er, sorry, yes, you are correct (4AM here...). But my point still stands.

@alexcrichton
Copy link
Member

I agree with @SimonSapin that it seems odd if I say "semver compatible with 1.0.0" that I'll start picking up 1.1.0-beta.1 or whatever new prelease becomes available. I think that Cargo may want to specially treat prerelease versions from that form of compatibility, but I think I've also seen some behavior like this in bundler in the past (@wycats perhaps you could clarify?)

I agree that there also may be an issue with the semver crate which needs to be handled as well, especially if we consider 1.0.0-beta to satisfy a requirement for 1.0.0

@wycats
Copy link
Contributor

wycats commented Jan 22, 2016

Two things that I think are somewhat uncontroversial:

  • 1.3.0-beta does not supersede 1.2.0
  • 1.3.0-beta-2 supersedes 1.3.0-beta-1

I'd like to suggest canonizing the concept of channels, so that:

  • 1.3.0-beta-1 supersedes 1.2.0-beta-6
  • 1.4.0-alpha-1 does not supersede 1.3.0-beta-6

The idea is to make the Rust-style release cycle more first class and give people a way, through their Cargo.toml, of subscribing to a particular "release channel".

We could also make subscription explicit through additional metadata:

[dependencies.nix]

version = "1.3.0"
channel = "beta"

This would always select the latest betathat also matches the semver versioning (1.5.0-beta-X would match, but 2.0.0-beta-X would not).

@SimonSapin
Copy link
Contributor Author

Two things that I think are somewhat uncontroversial:

1.3.0-beta does not supersede 1.2.0
1.3.0-beta-2 supersedes 1.3.0-beta-1

What do you mean by supersedes? “cargo update automatically upgrades form one (in your Cargo.lock file) to the other”? If so I agree.

Note that semver.org only talks about precedence, “how versions are compared to each other when ordered”. And 1.3.0-beta does sort after 1.2.0.

I'd like to suggest canonizing the concept of channels, so that:

1.3.0-beta-1 supersedes 1.2.0-beta-6
1.4.0-alpha-1 does not supersede 1.3.0-beta-6

Would there be a list of allowed keywords? semver.org allows arbitrary identifiers for pre-release versions.

@wycats
Copy link
Contributor

wycats commented Jan 22, 2016

What do you mean by supersedes? “cargo update automatically upgrades form one (in your Cargo.lock file) to the other”? If so I agree.

Precisely.

Would there be a list of allowed keywords? semver.org allows arbitrary identifiers for pre-release versions.

Arbitrary identifiers for channels would be allowed, but you could only upgrade across versions on the same "channel".

In practice, it is probably good to stick to a few well-known names like "nightly", "alpha", "beta", "rc", but they would not be interpreted as having any relation to each other across versions.

Just like in Rust, if you subscribe to the "beta" channel, you stick to beta.

@alexcrichton
Copy link
Member

One thing we'd need to figure out is what to do when we see a request like:

foo = "1.0.2-beta2"

That's valid by today's rules, but should that only match the package 1.0.2-beta2? Or perhaps 1.0.2-foo would auto-subscribe you to the channel foo?

@jethrogb
Copy link
Contributor

jethrogb commented Jul 31, 2016

I'm not seeing the behavior that's being talked about in the beginning of the post. In particular, cargo seems to not select prerelease versions at all? Specifying a dependency core_rustc-serialize = "*" (or core_rustc-serialize = "^0.3") results in Cargo choosing 0.3.19, while it should choose the newer 0.3.20-v0.3.19patch1. You can't specify 0.3.20-v0.3.19patch1 in either case by using cargo update --precise. Specifying the exact version string does work. I don't see any code changes linked here that suggest the behavior has changed though.

It seems people in here want cargo to not automatically update to higher prerelease versions, e.g. if the spec is "^0.3.19", it wouldn't update to "0.3.20-alpha". I understand that sentiment, but I think cargo should allow manually updating to that version in this case by using --precise.

@steveklabnik
Copy link
Member

cargo seems to not select prerelease versions at all?

In general, it shouldn't select prerelease versions unless you explicitly ask for prerelease versions.

@yozhgoor
Copy link

I had a problem recently with a published binary (see #9999). I published cargo-temp v0.2.3 using clap 3.0.0-beta.2, Today i was trying to install it via cargo install but it was not possible because i was trying to compile clap with 3.0.0-beta.5, with some breaking change compared to the beta.2.
I use a fixed version =3.0.0-beta.5 to avoid this, but this pitfall can be really silent

@cecton
Copy link

cecton commented Oct 24, 2021

I had a problem recently with a published binary (see #9999). I published cargo-temp v0.2.3 using clap 3.0.0-beta.2, Today i was trying to install it via cargo install but it was not possible because i was trying to compile clap with 3.0.0-beta.5, with some breaking change compared to the beta.2. I use a fixed version =3.0.0-beta.5 to avoid this, but this pitfall can be really silent

I just noticed now that leftwm-theme has the exact same issue! https://github.com/leftwm/leftwm-theme

imo this clearly shows that this is a pitfall

@djc
Copy link
Contributor

djc commented Oct 25, 2021

There was a discussion in the internals forum here: https://internals.rust-lang.org/t/changing-cargo-semver-compatibility-for-pre-releases/14820/10?u=djc.

The preferred suggestion seemed to be this:

maybe an alternative could be that ^1.0.0-beta.1 keeps its current behavior, but 1.0.0-beta.1 changes to be equivalent to =1.0.0-beta.1

Cargo team, does that need an RFC, or just an implementation?

@Eh2406
Copy link
Contributor

Eh2406 commented Oct 25, 2021

I think that will need a detailed description of how people opt in to the breaking change.

@djc
Copy link
Contributor

djc commented Oct 26, 2021

Okay, here are two directions that we could work out more.

Option 1: reuse the resolver version, bump it to "3" to mean enable sticky pre-releases.

resolver = "3"

Option 2: separate value:

pre-release-updates = "sticky" # or "default"

@Stargateur
Copy link

Stargateur commented May 9, 2022

Strike again, rwf2/Rocket#2166, I think a small fix could be that cargo consider by defaut that pre release is for exact version version = "0.1.0-alpha" == version = "=0.1.0-alpha" instead of current default behaviour version = "0.1.0-alpha" == version = "^0.1.0-alpha"

edit: #2222 (comment) I'm blind sorry for spamming.

@djc option 1 seem way better than option 2 cause option 2 require the user to always put this in every cargo.toml file, while "resolver = "3" is a naturel way to just update semver rule, and can be set by default in edition 2024.

@Stargateur
Copy link

Rfc proposition rust-lang/rfcs#3263 my first ever ! hope it's ok

@epage
Copy link
Contributor

epage commented Nov 29, 2022

As this is a more stable place to be holding this conversation, I'm moving a thread over from rust-lang/rfcs#3263

@ehuss said

The Cargo team discussed this RFC, and we think the following may be a viable path forward:

  1. Generate a warning if a dependency version has a pre-release version without a SemVer operator. For example, "2.0.0-beta.1" would generate a warning. The warning can be silenced by using an explicit operator like "=2.0.0-beta.1". The warning should recommend the = form. This is to make it a more explicit and conscious choice on how to use pre-releases.

  2. In the next Edition, Cargo will require prereleases to have an operator (changes the warning to an error).

There are some risks and drawbacks with this approach that may need some mitigations:

  • Users may get stuck in a scenario where they cannot update Cargo.lock because multiple packages are using a shared dependency with = pre-release versions, and they are out of sync. Using pre-release dependencies can be an inherently risky thing to do, so hopefully they are not used pervasively enough that it affects shared dependencies too often. I also feel that usage of pre-releases should be a temporary thing that should hopefully not be used for too long.

  • When using = dependencies, users are unable to force an update of a transitive dependency, even when they know it is compatible and safe. The package using the = dependency will need to make a new release to grab any new versions, and this can introduce significant friction and lag that can make using pre-releases frustrating. I suspect it is unlikely Cargo will gain support for some kind of forced-override in the foreseeable future, so this leaves the burden on users to publish new versions to stay up-to-date with new pre-releases (and switching to the final release once it leaves the pre-release stage).

  • Updating Cargo.toml to pick up a new prelease can be a tedious process without any sort of tooling support. cargo update currently doesn't provide any help here. I'm personally hopeful that cargo update could gain some kind of flag to also update Cargo.toml as discussed at https://internals.rust-lang.org/t/feedback-on-cargo-upgrade-to-prepare-it-for-merging/17101. This may also require another flag to force the = upgrade.

  • This will reduce the visibility of the availability of new pre-releases, which could normally be discovered with cargo update. I'm hopeful that maybe something like cargo outdated could be upstreamed to make it easier to see when newer versions are available.

  • cargo add should probably force the use of = (either implicitly add it, or generate an error telling the user to add it) when adding pre-releases.

  • We also discussed the possibility of more strongly encouraging users to mark their package as a pre-release if they have any pre-release dependencies. This could be done with a warning message, or possibly a warning during the cargo publish phase, or perhaps just more strongly worded recommendations in the documentation. I'm somewhat uncertain about this particular point, and I'm not sure it is something to gate the RFC on, but I think would be worth mentioning as something to explore (or at least have more discussion here).

@epage said

@ehuss my main concern with requiring an operator is scalability as it can't be automated. Take cargo-release of clap. I can make cargo release automatically add a = on switching to a pre-release but when doing the official release, I have no idea what the intended operator is. clap needs to use = on clap_derive independent of pre-release while I use the implicit operator for everything else. I'd need to go through and individually set the version requirement in each case. This is a 7 crate workspace. I would expect there are larger ones that would like to use pre-release. I feel like this creates enough friction that pre-release will continue to not be practical in cargo.

@ehuss said

Yea, that's another drawback where the switch is lossy. A few thoughts on that:

  • If using workspace inheritance, there should only be one place to fix these.

  • Presumably publishing pre-releases is a relatively rare event, so I wouldn't expect it to happen too often.

  • If automation is important, it may be possible to add some information on how to "revert" the change. For example it could add a comment, maybe something like:

    foo = { path = "foo", version="=2.0.0-alpha.1" } # cargo-release: pre-release-keep-equals
  • I think it might be good to more seriously consider the "channels" concept discussed at Pre-release version numbers cargo#2222 (comment) and similar to what is mentioned in the "Future possibilities" in this RFC. That is, a pre-releases will be considered compatible if the first component is the same, but incompatible otherwise (2.0.0-alpha.2 is compatible with 2.0.0-alpha.1, but not 2.0.0-beta.1). That would avoid the need for doing something like =, and provide an avenue for authors to make breaking changes. I think that could still be challenging, since many authors currently treat pre-releases as completely unstable, and won't know about these compatibility requirements which would be somewhat unique to cargo. Perhaps that could be alleviated if cargo gains something like semver checking during publish?

@epage
Copy link
Contributor

epage commented Nov 29, 2022

@ehuss

If using workspace inheritance, there should only be one place to fix these.

Its number of places and chance for errors (tracking what the requirements should be).

Presumably publishing pre-releases is a relatively rare event, so I wouldn't expect it to happen too often.

Its hard to gauge how much a feature would be used if it had better support. I minimized my use of it for clap v4 and I didn't bother switching my version requirements for the few pre-releases I did do, hoping I wouldn't have a breaking change affected by this, because I didn't want to deal with the version requirement clean up.

If automation is important, it may be possible to add some information on how to "revert" the change. For example it could add a comment, maybe something like:

I hope we can come to a better solution for handling of pre-releases than comment directives.

I think it might be good to more seriously consider the "channels" concept discussed

While channels might be useful in some contexts, I doubt they can generally resolve this problem. A lot of pre-release testing is more one off; people aren't as likely to continually want to do pre-release testing unless they are patching their crates to get ahead of time notice, like people testing nightlies in CI.

That is, a pre-releases will be considered compatible if the first component is the same, but incompatible otherwise (2.0.0-alpha.2 is compatible with 2.0.0-alpha.1, but not 2.0.0-beta.1

imo this is a non-starter. If clap v3 had to have a separate channel for each pre-release breaking change, we would have run out of well-recognized channel names. I had brought this up in one of the other threads that we cannot assume semver compatibility even within the same pre-release type as it doesn't make sense to constrain compatibility on that axis.

@ia0
Copy link

ia0 commented Nov 30, 2024

Trying to sum up the issues and efforts regarding pre-releases and how I see the design space.

General context

SemVer provides a system (called Semantic Versioning) to document versions of the code of a given package based on the version number only:

  • Compatibility (a preorder): Whether the public API of a given version of the code is guaranteed to be compatible with the public API of another version of the code1.
  • Precedence (a total order2): Whether a given version of the code is more "recent" than another version of the code.
  • Stability (a predicate): Whether a given version of the code is guaranteed to be stable.

Note that SemVer only provides "baseline" guarantees. The user may provide additional guarantees in their own package documentation (for example compatibility across different major versions if only a given subset of the public API is used). Similarly, package managers may also provide additional guarantees for all packages they manage (for example stability for releases with a major number of zero).

Note that those 3 notions are interconnected. For stable versions, compatibility is "contiguous" with respect to precedence. If A, B, and C are stable versions where A precedes B, B precedes C, and A is compatible with C, then A is compatible with B and B is compatible with C. This "strong transitivity" permits using ranges of stable versions in a "compatibility-friendly" way, and meant to be leveraged by package managers for dependency management.

What works well

  • Cargo extends stability to versions with a major number of zero3, essentially making pre-releases the only unstable versions4.
  • Cargo uses "compatibility ranges" for dependency management thanks to the "strong transitivity" of SemVer5.

What doesn't work well

Cargo extends compatibility to pre-releases as follows:

  • Pre-release A is compatible with pre-release B if they share the same release and A precedes B, i.e. 1.0.0-alpha is compatible with 1.0.0-beta but not with 1.0.1-alpha or 1.0.1-beta.
  • Pre-release A is compatible with its release, i.e. 1.0.0-alpha is compatible with 1.0.0 and thus 1.2.3 by transitivity.

The problem of extending compatibility like that, is that this is "maximal" and leaves no freedom to users (because the "baseline" guarantees are the "maximal" guarantees). Concretely, with this extension, a library author cannot introduce a breaking change within pre-releases of a given release. In practice, they will because they must, and now the exact scenario that SemVer is supposed to prevent happens: cargo update in a crate depending on such pre-release will magically break.

Instead, Cargo should have extended compatibility to pre-releases in a way that leaves some freedom to library authors to release both breaking and non-breaking changes within pre-releases of a given release. See the last section for an example of such extension.

Immediate workaround

Recommend to use >=2.0.0-rc.4.3, <2.0.0-rc.5 when depending on a pre-release to let library authors publish pre-releases with and without breaking changes (even though currently Cargo assumes a release or pre-release is never a breaking change from a pre-release of the same release). In particular, a pre-release version requirement should not use ^ (prevents the library author to introduce a breaking change) or = (prevents the library author to introduce a compatible pre-release).

Planned efforts

  • The Cargo team mentioned wanting a lint to warn of this issue and advertise workarounds (for example the one presented above).
  • Cargo wants to split and extend compatibility even more by making a release compatible with a pre-release within its compatibility range (with respect to precedence), see RFC: Precise Pre-release cargo update rfcs#3493. This only affects cargo update --precise so is not really problematic (this is a different kind of compatibility, like an "update compatibility" versus a "resolution compatibility", so quite orthogonal in practice).

Extension example

Why did SemVer come up with the notion of unstable versions? This question should drive the design around pre-releases.

I believe unstable versions are meant as development branches that you can publish without impacting your stable branch. In SemVer, your stable branch is composed of stable versions (releases with non-zero major number). Your development branches are unstable versions (releases with zero major number for initial 1.0.0 release development and pre-releases for other releases development).

Here is how I would deal with development branches in a uniform way (this is not meant to be adopted by Cargo, but could be lints/recommendations for users and a coherent general direction for incremental changes). The core idea is to consider development branches as forks of the package, for example [email protected] is a shortcut for [email protected] (i.e. introducing a fork foo-x_y_z_n of foo and publishing a version 0.a.b in that fork). Such forks are only meant to reach 1.0.0 and stop there. That stable version would be [email protected] and mark the end of that development branch.

Concretely, this alternative extends SemVer as follows:

  • Forbid a major number of zero. (The initial release is 1.0.0.)
  • Forbid pre-releases that are not -n.a.b where n is a non-numeric identifier (e.g. alpha, beta, or rc) while a and b are numeric identifiers.
  • Guarantee that x.y.z-n.a.b is compatible with x.y.z-n.a.c when b <= c.
  • Define 0.x.y as a syntactic sugar for 1.0.0-rc.x.y.
  • Define x.y.z-a.b as a syntactic sugar for x.y.z-rc.a.b.
  • Define x.y.z-a as a syntactic sugar for x.y.z-rc.a.0.

In addition to "strong transitivity" for the stable branch, we have "strong transitivity" for each development branch too. Version requirements are valid only if all matched versions are in the same branch. For example:

  • >=1.0.0, <2.0.0 is valid
  • >=1.2.3, <2.0.0 is valid
  • >=1.2.3-rc.4.5, <1.2.3-rc.5.0 is valid
  • >=1.2.3-alpha.4.5, <1.2.3-beta is valid (the library guarantees that only the non-numerical identifier is used for breaking changes, possibly for a specific subset of the API)
  • >=1.2.3-rc.4.5, <1.2.3 is valid (1.2.3 is by definition the end of the 1.2.3 development branch)
  • >=1.2.3-rc.4.5, <1.2.4 is invalid
  • >=1.2.3-rc.4.5, <1.2.4-alpha.0.0 is invalid
  • >=1.0.0, <2.0.0-alpha.0.0 is invalid

Possible incremental changes with this design in mind would be:

  • Recommend a x.y.z-n.a.b style for pre-releases (with n and b optional) with the compatibility meaning defined above, highlighting that this is only a convention, and requires users to specify the version requirement as >=x.y.z-n.a.b, <x.y.z-n.(a+1).0 when depending on x.y.z-n.a.b.
  • Add a syntax for the version requirement above, e.g. #x.y.z-n.a.b means >=x.y.z-n.a.b, <x.y.z-n.(a+1).0 (again where n and b are optional). Alternatively, the syntax could be {x.y.z-a.b...f.w}.g.h... to mean >=x.y.z-a.b...f.w.g.h..., <x.y.z-a.b...f.(w+1) or a similar syntax that splits the pre-release in a prefix of identifiers, a numerical identifier, and a suffix of identifiers. The upper bound is defined as the incremented numerical identifier. This gives more freedom to library authors to choose how they want to define pre-releases, but it adds more cognitive load on library users because they need to check the library documentation to know where to split.
  • Make this syntax the default for pre-releases. This is a breaking change and needs the points above to have settled.

Footnotes

  1. "A is compatible with B" means that code depending on A won't be broken by depending on B instead. This might be the opposite direction of how it's used in practice, but works better with precedence and symbols like A ≤ B or A → B.

  2. Ignoring build metadata. Cargo actually forbids (or highly discourages) versions differing only by build metadata thus enforcing a total order on crates.io.

  3. Cargo also extends compatibility appropriately to preserve the "strong transitivity" property of SemVer for stable versions.

  4. Cargo describes 0.0.x as "permanently unstable" even though it is a stable version (with Cargo's extension of stability). This is because the only stable version it can be compatible with is itself, so its stability guarantee is essentially useless, thus all 0.0.x versions are kind of unstable, hence "permanently unstable".

  5. This only applies to stable versions by definition of "strong transitivity" (including those newly added stable versions).

@epage
Copy link
Contributor

epage commented Nov 30, 2024

imo that is too dramatic of a shift to move the ecosystem through and is a superset of the problems with just making pre-releases assume =.

@ia0
Copy link

ia0 commented Dec 1, 2024

Making pre-releases assume = will just swap one problem with another (as said in the previous post):

  • If pre-releases use = by default, then your build will break if your dependency graph contains 2 different pre-releases of the same package (e.g. =2.0.0-rc.1 somewhere and =2.0.0-rc.2 somewhere else).
  • If pre-release use ^ by default, then your build will break on cargo update or git clone after a new incompatible pre-release is published.

Note also that in the list of problems, there's also the fact that Cargo defines compatibility ranges regardless of the branch. It is always the major number of the version, even if it's a pre-release. The correct definition would be (it simply follows from the "package fork" model):

  • Major number for releases (stable branch).
  • Release and "major number" (aka n.a) for pre-releases (development branches).

Without doing this correctly, you will get diamond problems. For example, the case above having both =2.0.0-rc.1 and =2.0.0-rc.2 in the dependency tree, should actually build by using both versions (as if you had 1 and 2) since they are in different compatibility ranges.

imo that is too dramatic of a shift to move the ecosystem through

Yes, this is definitely a major change, and shouldn't be done at once (if at all). I never said that. However, it is the north star defining the general direction for incremental improvements (like a warning to not rely on the default version requirement for pre-releases, and instead specify precisely what is meant, which is currently package-specific, so each package should document its pre-release behavior).

Said otherwise, agreeing on a north star is necessary, otherwise incremental improvements are just Brownian motion, where improvements may introduce new problems (either now or in the future by preventing another better improvement).

It is important to have a consistent long-term strategy regarding pre-releases. And it's not because it's a hard problem that it should not be handled. I suggested such a long-term strategy above. It doesn't need to be implemented even in 10 years from now, however incremental improvements should approximately align with this general direction.

@ia0
Copy link

ia0 commented Dec 1, 2024

Another example of incremental non-breaking changes1 that aligns with this north star and fixes all issues:

  • You can't break the build of your users with breaking changes.
  • Your users can depend on incompatible versions2.

Document the limited use of pre-releases

Pre-releases are useless because they assume you're able to get every change right from the first version you introduce it. This goes against the idea of pre-releases, which is to experiment without commitment. The workaround is to fork your package for each release for which you want to experiment.

For example, if you want to experiment before committing a 2.0.0 for your foo package, then you can temporarily rename your package to foo-2 (or foo-2_0_0) and change the version to 0.1.0. You can publish this package and tell your users to use it if they want by replacing foo = "1.2.3" with foo = { version = "0.1.0", package = "foo-2" }. You should never reach 1.0.0 in foo-2 since that release should be 2.0.0 of foo. For that you rename back your package to foo and change the version to 2.0.0. After publishing, you can tell your users to use foo = "2.0.0".

Note

The proof that pre-releases are useless is by case over the 3 types of pre-releases:

  • For major pre-releases (towards x.0.0), all versions of this pre-release must be compatible according to precedence, including with the final release. For example, 2.0.0-0 must be compatible with 2.0.0-1 which must be compatible with 2.0.0-2 and so on until the last one is compatible with 2.0.0. Said otherwise, you have to get all the major changes in the first version of the pre-release. You also have to get all minor changes right from the first version they are introduced.
  • For minor pre-releases (towards x.y.0), there's an additional restriction to those of major pre-releases: the last release preceding the first pre-release must be compatible with the pre-release3. For example, 1.2.3 must be compatible with 1.3.0-0. That's not a problem, but here for completeness. However, similarly to major pre-releases, you also have to get all minor changes right from the first version they are introduced.
  • For patch pre-releases (towards x.y.z), there's an additional restriction to those of minor pre-releases: you can't do any minor change due to transitivity. This is also not a problem, but here for completeness.

Warn when publishing pre-releases

Print a warning when publishing a pre-release. The message would point to the documentation of pre-releases and suggest immediately yanking the pre-release.

This could eventually become a breaking change if needed (to avoid the yanking), by adding a --let-me-break-my-users-with-pre-releases flag to cargo publish and returning an error when publishing a pre-release without this flag. The error message would point to the documentation of pre-releases and to the escape hatch flag in case the risk is measured.

Footnotes

  1. This leverages the fact that Cargo extended compatibility correctly to the unstable versions that are the releases with a major version of zero. Those unstable versions are quite similar to pre-releases, they just apply to the initial release instead of an arbitrary release.

  2. As long as the API of those versions don't interact. This is the same restriction as for releases.

  3. That's actually anticipating the stabilisation of https://github.com/rust-lang/rfcs/pull/3493.

@steveklabnik
Copy link
Member

steveklabnik commented Dec 2, 2024

Why did SemVer come up with the notion of unstable versions? This question should drive the design around pre-releases.

I believe unstable versions are meant as development branches that you can publish without impacting your stable branch.

Not really. In fact, they're basically the complete opposite version of this:

Pre-releases are useless because they assume you're able to get every change right from the first version you introduce it. This goes against the idea of pre-releases, which is to experiment without commitment.

The way prereleases are used in Rails, which is the community that semver came out of (well Ruby more generally, and Rails actually doesn't follow semver more generally, but that's neither here nor there right now, the point is this is how these semantics originally came to be), are effectively release candidates. You get releases like these (real example):

  • 3.0.6 - April 05, 2011
  • 3.0.6.rc2 - March 31, 2011
  • 3.0.6.rc1 - March 29, 2011

Note the use of rc: these are release candidates. The idea is to let users try out a release before it's actually published, to let people kick the tires. Sometimes, regressions sneak in, or there's problems, so you cut another release candidate with the fixes, and then once you're satisfied, publish the real version.

Sometimes there are pre-releases named "beta", like for example, Rails 3.0.0 was a massive release, so you had this:

  • 3.0.0 - August 29, 2010
  • 3.0.0.rc2 - August 24, 2010
  • 3.0.0.rc - July 26, 2010
  • 3.0.0.beta4 - June 08, 2010
  • 3.0.0.beta3 - April 13, 2010
  • 3.0.0.beta2 - April 01, 2010
  • 3.0.0.beta - February 05, 2010

The beta releases are a "it's getting into shape but we aren't committing to exactly this just yet" style release, so this is closer to what you're talking about.

This is also why the behavior is the way it is, historically. The use case is "I want to let people opt in to kicking the tires." And that's why something like ^3.0.0 doesn't match a prerelease, but ^3.0.0-rc1 would end up matching 3.0.0-rc2: people are knowingly opting in to breaking changes. That's the whole point of depending on a prerelease!

Anyway, I know that there's tons of desire to change this behavior, but l've always thought it makes sense.

@ia0
Copy link

ia0 commented Dec 2, 2024

The idea is to let users try out a release before it's actually published, to let people kick the tires. Sometimes, regressions sneak in, or there's problems, so you cut another release candidate with the fixes, and then once you're satisfied, publish the real version.

Wait, how is that different from a development branch? I think we're saying the same thing with different vocabulary. The other word I used is "experiment without commitment". We can also say "let users check if it matches their needs". That's all the same thing to me. Feel free to clarify the difference if you see one, so I can use your vocabulary.

The use case is "I want to let people opt in to kicking the tires." And that's why something like ^3.0.0 doesn't match a prerelease, but ^3.0.0-rc1 would end up matching 3.0.0-rc2: people are knowingly opting in to breaking changes. That's the whole point of depending on a prerelease!

Sorry but I don't see a logical implication between the use-case and opting in breaking changes (which I understand as uncontrolled build breakage1). What I described is an example of fulfilling the use-case while preventing uncontrolled build breakage. Let me give a concrete example if it helps:

  • There is some stable version, say 2.5.1.
  • You have a few issues you want to fix, but they need breaking changes. So you start working on a 3.0.0.
  • Because you're not sure you're gonna get it right on the first try, and don't want to publish a 3.0.0 just to publish a 4.0.0 a few months later, you start a 3.0.0 pre-release (or development branch).
  • You regularly publish this development branch to get feedback from your users. They could depend on specific commits of the git repository, but you want to make their life more convenient (assuming your package manager is helping), so you really publish versions from this development branch.
  • At first you start with 3.0.0-alpha.1.0. Then with user feedback and compatible changes you publish 3.0.0-alpha.1.1, 3.0.0-alpha.1.2, etc.
  • Eventually a user feedback requires a breaking change, so you publish 3.0.0-alpha.2.0.
  • And so on until eventually you start getting confident that there will be less breaking changes, so you publish 3.0.0-beta.1.0 with the intent of letting more users start testing the development branch.
  • The same type of increments happen, but ideally staying within 3.0.0-beta.1.x or 3.0.0-beta.2.x.
  • After a few months of testing by your users, you are confident enough to reach the ultimate stage of development: 3.0.0-rc.1.0.
  • Reaching anything else than 3.0.0-rc.1.x would be catastrophic because you would break the promise that this only needs minor adjustments before going stable (i.e. to the stable branch).
  • After a year, you've been at 3.0.0-rc.1.5 for 6 months, so you decide to release it as 3.0.0 and document that this stable version is exactly 3.0.0-rc.1.5 and thus compatible.

Now, if your package manager understands that 3.0.0-n.a.b is compatible with 3.0.0-n.a.c when b <= c (and that's the only way for pre-releases to be compatible), then you can be maximally useful to your users (I mean those that decided to help you test 3.0.0):

  1. You will never break them by publishing a new version.
  2. You will let them resolve different compatible versions to the most recent one (e.g. 3.0.0-alpha.3.2 and 3.0.0-alpha.3.7 to 3.0.0-alpha.3.7).
  3. You will let them use incompatible versions as long as they don't interact (e.g. 3.0.0-beta.1.7 and 3.0.0-beta.2.1).

If your package manager doesn't help you because it considers all pre-releases to be compatible, then you can at least tell your users to only use >=3.0.0-n.a.b, <3.0.0-n.(a+1).0 and you will still provide them (1) and (2). However, you won't be able to provide them (3) because your package manager thinks the range >=3.0.0-0, <4.0.0-0 (i.e. all versions with a major number of 3, including pre-release) are compatible, and thus will fail to resolve 3.0.0-beta.1.7 and 3.0.0-beta.2.1 in the same dependency tree to be different (incompatible) versions (if you had 0.1.7 and 0.2.1 instead, it would have done so).

Anyway, I know that there's tons of desire to change this behavior, but l've always thought it makes sense.

Do you mean it makes sense that users either stay on the stable branch or opt-in to uncontrolled build breakage in the development branch? It makes much more sense to me to be able to both opt in a development branch and preserve the SemVer property of never getting a broken build because a new version is published. Said otherwise, when you opt in a development branch, you opt in more maintenance when bumping the n.a of that dependency (the amount of maintenance depends on n, which can be alpha, beta, rc, or whatever the library author documents). But this is controlled, which means you can use a pre-release dependency while still publishing releases (i.e. using a development branch doesn't force you to be on a development branch).

This non-contaminating aspect is very important for scale. Think of it like unsafe. You want to be able to encapsulate unsafe. That's the same with pre-releases. Today in Cargo, you can only use = for that. But this only fixes (1). You still get (2) and (3) failing your users.

Footnotes

  1. Otherwise you simplify fully agree with what I said, which doesn't make sense if you start your comment with "Not really. In fact, they're basically the complete opposite version of this".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-dependency-resolution Area: dependency resolution and the resolver A-semver Area: semver specifications, version matching, etc. C-bug Category: bug E-hard Experience: Hard S-needs-design Status: Needs someone to work further on the design for the feature or fix. NOT YET accepted.
Projects
None yet
Development

No branches or pull requests