>I agree with the sentiment that we should only increment major versions when making breaking changes, but more often than not breaking changes are really easy to accommodate for.
Maybe on a small scale breaking changes don't cause issues, but in large-scale development, you simply can't afford to have a bunch of random CI/CD pipelines fail because a developer decided to "accommodate for" a backwards-incompatible change without separating it to a new import path.
>Using different paths for different major versions makes more sense in situations where we may require two different versions of the same package, you know, diamond imports and all that. This is the exception, not the rule, and it seems strange to tap dance around a problem that doesn’t exist in most codebases.
Diamond imports are not as rare as you think - it's just that you never run into it in your own code, but in the code of your dependencies. And in Go, the problem is already solved for you so you never even see the solution at work.
Let's say you use two libraries for, say, backend and frontend - libbackend and libfrontend. Let's say they both depend on some parser library, say libparser, version 1.0.
Now assume that libbackend upgrades the libparser dependency to 2.0, but the import path stays the same and your application - the one that has been successfully auto-updated on a CI/CD server for years and nobody remembers how it works anymore - breaks, and you have no idea why, compiler reports some weird API-incompatibility errors, and now you have to spend two weeks getting back into the codebase and debugging the issue, until you realize you can't really fix it, because the guy developing the language decided that diamond imports are "a problem that doesn’t exist in most codebases".
> Now assume that libbackend upgrades the libparser dependency to 2.0, but the import path stays the same and your application - the one that has been successfully auto-updated on a CI/CD server for years and nobody remembers how it works anymore - breaks, and you have no idea why, compiler reports some weird API-incompatibility errors, and now you have to spend two weeks getting back into the codebase and debugging the issue, until you realize you can't really fix it, because the guy developing the language decided that diamond imports are "a problem that doesn’t exist in most codebases".
The same scenario is very likely to happen, but much worse, if libparser was upgraded from 1.1.1 in 1.2.3 for libbackend. This will mean libfrontend, in your app, now also uses libparser 1.2.3. But, libparser 1.2.3 has a bug that affects libfrontend, but not libbackend. But of course your CI/CD won't tell you that, because Go is "smart" (and so are NPM, Cargo, etc) and instead of letting you know you have a depe dependency mismatch and can't upgrade libbbackend, they let you have fun debugging a runtime issue. Odds are good you will spend a good part of the day before even realizing that libfrontend, which says right there that it uses libparser 1.1.1, is actually being compiled with a different version because something else changed.
The moral of the story: SemVer is a lie. If you want a long standing project to keep running, you pin specific dependency versions for every library, and always allocate a good chunk of time for any upgrade. "Automatic upgrades" of dependencies are a nice dream, just like code that has no bugs.
Just want to mention that Cargo (don't know about others like NPM) handles it a bit differently.
You are quite flexible in specifying the versions of your direct dependencies and you can fix major, minor or patch versions or do even more complex stuff with version ranges (even though I see the latter rarely used in practice).
In addition, different versions of the same crate can coexist (but their types are considered distinct). So if I directly use libparser 1.1.1 but one of my dependencies uses libparser 1.2.3 that's totally fine. The version of my direct dependency won't silently be upgraded (assuming you fixed the minor or even the patch version when specifying that dependency).
Honestly, my only complaint about go mod major versioning is just how unintuitive versions after v1 are. While some may disagree about the tradeoffs, I think the reasoning behind it is totally solid, but there are definitely valid complaints about the exact behavior of modules being somewhat confusing. I recall confusion with trying to update a library with no tagged versions, too, for example.
That said, I think people greatly undersell how well Go has done packaging. There’s, again, tradeoffs that are at least arguable; but, I hate when people say things like “Didn’t (npm|apt|cargo|…) already solve this?” Honestly, often times the answer is at best “kind of?” — a lot of these packaging and versioning problems remain without complete solutions. Meanwhile, Go has some novel design choices that set it apart. The module proxy is a slightly unfortunate tradeoff, but Go remains one of the only languages with a good story for mostly decentralized package management. The module proxy is more of a hack that gets you some of the advantages of centralization without strictly depending on it. But beneath that, it’s nice that you can pull packages from basically anywhere with several VCSes.
Ya I spent an hour once trying to figure out why I couldn't get a dependency to upgrade to the latest version then it finally hit me and I was like "oooooh ya that stupid major versioning path change thing". I find it very unintuitive.
It would be great to be able to declare a dependency with an alias at the go.mod level. Rather than requiring the publisher of a dependency to update their paths you can choose.
That way if a dependency does a minor upgrade that breaks your code but you also need some new functionality, you could depend on the old code in a particular package and new code in another.
kardianos commented on May 23, 2018:
One goal of vgo is to not make the source go files dependent on the module file. If the vgo file is removed, the worset result is a fresh build gets the latest in major version series.
This proposal appears to break that principal.
Isn’t a reasonable solution there simply to wait until libfrontend has also updated to libparser 2 before updating your app’s libbackend dependency? Why would you need to auto-update libbackend on its own?
It’s not too hard to imagine in a larger code base, where maybe libfrontend is relatively stable but libbackend has active development. Now if I want to update libbackend (maybe a new feature was added, maybe there’s been a critical bug fix) I shouldn’t have to wait for the libfrontend maintainers to update their dependencies, sort out any breaking changes, and verify there were no regressions to do so.
Prior to go modules this was even worse with third party packages. I’ve had cases where my app has integrations with a few google services like maps, sheets, and text-to-speech. It turns out the libraries for all of these services, understandably, also have certain dependencies in common. If you run in to the unfortunate situation where you need to update one of the google libraries, and that updated library now relies on an updated version of a dependency required by others, you’ll end up needing to update all of the other libraries. Granted it’s usually a good idea to update, but it turns a theoretical 1 hour task (update library to use new feature) in to a headache that might take several days depending on how big the changes were to the other libraries
> Granted it’s usually a good idea to update, but it turns a theoretical 1 hour task (update library to use new feature) in to a headache that might take several days depending on how big the changes were to the other libraries
Well, you will anyway have to spend a few days, often. You either spend it taking a new version of several libraries, or you spend it digging through bizarre issues because your upgrade of maps broke your integration with sheets, since your older sheets was never tested with the newer shared library version and has a bug.
Absolutely agree. Speaking as someone who has recently had to upgrade multiple medium-sized Scala/Java apps. Diamond imports are absolutely a big problem. Many of these programs simply couldn't be built on newer Scala versions because of dependency conflicts and required portions to be rewritten. Incredibly painful!
> Due to our size, we don’t need any kind of backwards compatibility, we just update everything.
Then just use 0.y.z versions and be done with it?
If the library constantly changes and everybody expects that, then that seems fitting.
I like Go's major version handling very much. If it's backwards incompatible, it's basically a new library under the same name and development team.
In my opinion making major version updates so painful also incentivizes not making backwards incompatible changes in libraries, which results in the Go ecosystem being very stable overall (something I value a lot in my day-to-day).
>If it's backwards incompatible, it's basically a new library under the same name and development team.
This, this, and a hundred more times this.
Incompatible is incompatible.
There is no "kinda incompatible", "99% compatible" - when it comes to dependencies, they either work properly or they don't.
Software should be eternal. Without being strict about semantic versioning, it is impossible to make it so.
I can change one part of an API and leave another untouched - that's part compatible, part incompatible. It's only an issue if you used the changed part.
(If you think that first one always counts... what if the changed part is literally called YouMustNotUseThisFunctionOrYourCodeWillAlwaysBreak() ? It's clearly implied to not be part of your intended API, despite technically being part of it.)
I can add something to a type, in a way that's backwards compatible at compile time... but common reflection patterns might cause everyone's code to explode.
I can make a change that solves a bug that someone was accidentally relying on by doing the wrong thing, but doesn't affect compile-time behavior, nor runtime for anyone using the library the way they should. But that bug-user's code is now broken, is this an incompatible change?
> I can change one part of an API and leave another untouched - that's part compatible, part incompatible. It's only an issue if you used the changed part.
When you commit to a version 1, you assume that every user of the package is using every feature you provide through your official API. If you break any slightest piece of the API, you've broken compatibility. It might "kinda work" for many users, but it will almost surely cause significant pain for many others.
> I can make a change that solves a bug that someone was accidentally relying on by doing the wrong thing, but doesn't affect compile-time behavior, nor runtime for anyone using the library the way they should. But that bug-user's code is now broken, is this an incompatible change?
It is not an incompatible change, and it is the responsibility of the bug-user's code to fix the bug in his program.
Of course, when such a thing happens on a large-enough scale, the API developer sometimes cannot afford to "fix" the behavior and force countless users to fix their programs, so the quirk just becomes de-facto part of the API.
Even as someone who has a lot of sympathy for strong backwards-compatibility guarantees, the position you have put forth here is ridiculous to the point of absurdity and is a level of backwards-compatibility that I assure you you have never once experienced from any library (including SQLite, which is generally considered to be extremely pedantic about compatibility and yet hasn't even attempted to live up to your impossible standard).
It's not necessarily a commitment to an impossible level of backwards compatibility, but rather a commitment to communicate any failure to attain that level clearly by bumping your major version number. I've found that to be pretty doable. (Which does usually come with a commitment to at least strive to attain that level, and to batch changes that do not live up to it.)
> When you commit to a version 1, you assume that every user of the package is using every feature you provide through your official API. If you break any slightest piece of the API, you've broken compatibility.
That is not how semver works. If you read the documentation [0], it says right there that the public API must be clearly defined but that can also happen through documentation and you only have to up the major version if you break that specified public API.
You can certainly consider it bad programming practice to expose something via the means of the language that is not defined as public API but it is not against semver to do so.
If you think that way, then everything is incompatible, because the person using your lib could always do a hash of your lib file and create code that depends on it:
if hash(libpath) != predefinedHash {
log.Fatal("broken compatibility teehee")
}
In the context of semver, "API" means "public API". There is no other API but public API.
Yes perhaps I misunderstood you. I was trying to point out that semver is okay with having elements in the "public" API of a library (public in the sense of you can import and use it, i.e. not hidden in private modules etc) but explicitly documenting that it is not considered public API in regards to semver.
If that is what you meant with "official API" then we are on the same page.
How do you define "compatible"? Semver doesn't define it.
An HTTP server can remain API compatible, and still drop or break support for a major feature you care about. Surely you don't want that to ship in a patch release.
Adding a new struct field, even a private one, can break API compatibility in Go if people use your struct without named fields. Do you want a new library in this case or not?
Semver doesn't cover CLI compatibility, either, do you want a CLI redesign to remain v1 or become v2?
Nuance matters. Stating to "just be strict about semantic versioning" doesn't help, semver is fuzzy.
I don't think this is all that useful of a quote outside of operating systems (and even there I still find it of questionable value). You really need to define how people can use your software (at least at a high level) and receive backwards compatibility guarantees.
Even in the Linux example, kernel modules do not receive compatibility guarantees because it is difficult to keep. You may need to rewrite your module depending on how it is written when upgrading the kernel. It also doesn't apply in case of security vulnerabilities and certain classes of bugs. Technically viruses can be user programs which rely on those vulnerabilities and even excluding those, there are cases where some API seems benign but later turns out to be flawed (like precise timers in the context of browsers).
I think it's a difference in what one considers to be "the API". Linux is very much to the de facto API side, whereas some other project might be more one the de jure API side with a rigid specification and allowing any change within that. Most things are probably somewhere between the two.
I'd define it as 'everything you've documented so far still works as documented, and possibly if you're aware that many of your downstreams are relying on some undocumented behaviour, you try to account for that too (but that part is a nicety, not a commitment).
For some examples of how complicated determining what is "breaking" can be in general, here's a StackOverflow wiki that I created a long time ago to document this kind of stuff for .NET:
ComVer doesn't account for changes that don't change the API.
For example, assume that you're using a dependency, libparser, v1.0, which uses ComVer. In version v1.1 they add a new function, foo() which does something that you want. However, at the time of you writing the software, the latest version was v1.2, which you use because you're a good engineer who likes to keep things up-to-date.
Now think about what happens when you distribute your application to three people:
- person 1 has libparser v1.0 - you program can't work, because v1.0 doesn't have foo(), so you obviously must require minor version that your program was built with - which is v1.2
- person 2 has libparser v1.2 or higher - you program works.
- person 3 has libparser v1.1 - you program CAN work, since v1.1 contains foo(), but it WILL NOT work, because your application requires at least v1.2.
Now, you might say, "well a developer should know that foo() was added in v1.1 and pin that as the lowest required vesion" - in which case I reply that ComVer doesn't help you a tiniest bit with this, because both API extensions and bugfixes are mashed together into a minor version, so now you have an order of magnitude more changelogs to go through.
SemVer stricly separates API extensions and bugfixes so you can just browse the minor version changelogs and see which version added the feature you require.
I’ve added a method to a class. You use the same method signature but with different meaning in your code base. My release breaks your duck typing code. Was my release compatible? Is that an equivalent API?
If a class with its methods is a part of your API, and you add another method to the class that doesn't change the behavior of other methods, that it's a backwards-compatible release.
Other than that, I don't understand your question. What does "use the same method signature but with different meaning" mean? What is my "duck typing code"? Is duck typing part of your API?
In golang, you can reasonably have a collection of things accessed through interfaces, check whether each one conforms to additional interfaces, and call the methods of those additional interfaces if so. If user code does this, then adding a new method to a library can cause user code to break.
You may say "Well, don't do that," but it seems like golang users think of this as a way to provide backwards-compatible API upgrades[0].
Switching to Python examples from Go, but I believe it all applies just the same.
> What does "use the same method signature but with different meaning" mean?
.draw(times: int)
In your code that adds a shape to the canvas. In my code it adds a card to your hand.
> What is my "duck typing code"?
You collect everything with the draw signature as options to draw with.
> Is duck typing part of your API?
What do you mean “your API?”. Maybe I mention it in the docs, maybe I don’t. But that doesn’t really matter because if we’re going to compare intent, then this is subjective. And it really doesn’t matter because duck typing isn’t part of an API, it’s a technique / language feature. By just adding a method, I removed your ability to do something.
My point with all this is your definition is lacking. You just shifted the pain of defining “breaking change” to the pain of defining “compatible API”. Which is fine if you’re then going to define that strictly (see Hickey’s talk on SemVer), but you haven’t.
1) Machine-readable definitions of the functions and data types used
2) Human-readable explanation of what the previously defined functions do when passed each of the allowed data types, with a high-level explanation of how is the API supposed to be used.
The first part is usually implemented as part of your programming language - in case of Python, type hints can be used as a substitution.
The second part is necessarily informal, since it targets humans. But that does not mean it cannot be well-defined.
For example:
mmap() creates a new mapping in the virtual address space of the calling process. The starting address for the new mapping is specified in addr. The length argument specifies the length of the mapping (which must be greater than 0).
There is no concept of "virtual address space" or "memory mapping" in C. All C knows about is functions and values. But we still know what mmap() does and there is absolutely no ambiguity to its behavior.
> Other than that, I don't understand your question.
It's simple: a user inherits from your class and adds a method to their user-defined subclass. You then ship a new version of your library that adds a method with the same name to your class, but with different semantics, and other parts of your library call this method expecting it to have the semantics you defined. You've just broken the user's code, even though you say this was a "backwards-compatible" release, and it isn't the user's fault because they were using your library as it was documented.
> There is no "kinda incompatible", "99% compatible" - when it comes to dependencies, they either work properly or they don't.
> Without being strict about semantic versioning, it is impossible to make it so.
There is no semantic versioning if you're that strict. You have to update the major version on every single release, no matter the contents, if your idea of backwards compatibility is that absolute.
Let me give you a concrete example: I have a go package with this code:
var ErrBadThing = errors.New("somthing bad happened")
func DoThing() error { /* maybe return ErrBadThing */ }
I notice I had a typo in my error message, "somthing" when I wanted "something". I fix that typo. Is this a breaking change?
The answer is "it depends"
err := mypkg.DoThing()
if errors.Is(err, mypkg.ErrBadThing) { /* will still work after change */ }
if strings.Contains(err.Error(), "somthing") { /* will no longer work after change */ }
So, is it a breaking change or not? I would argue "no, not breaking, if the user uses the API as expected, it does not break", but you may argue otherwise.
If you argue that is a breaking change, well, what about adding a new method, which breaks reflection (like 'reflect.NumMethod()' returns one more, so if someone relied on indexing into your methods with reflection, you broke em!)? What about someone downstream applying a "patch" to your code before compiling it? Any change can break that.
The go authors have taken a much less strict approach. Go is still semantically "v1", but they've made a ton of breaking changes, from making `net/http` silently switch to http/2, to changing the semantics of various tls and security related functions, etc etc.
I would argue that you cannot do semantic versioning in a useful way without an API specification of some sort. For most projects, that would be the API documentation. If the only specification is the implementation, then every behaviour of the library is in the spec and every change is a breaking change.
No, it really shouldn't be. It makes it impossible to distinguish between those "theoretically this a breaking change" kind of changes and the "you're going to need to rewrite a bunch of code" kind of changes. At the very least libraries should be allowed to define how the library must be used in order for their semantic versioning to apply. For languages which allow "import * from x" style imports, the library should still be allowed to add in new functions without that being a breaking change.
If you want your software to be pristine forever, you really need to pin your dependencies, ideally via copying the source of the library into your repo so you aren't reliant on a package manager being available in the future. For library developers, regardless of versioning scheme you need to avoid ANY breaking changes whatsoever. Instead of changing an existing function, introduce a new one so that your downstream users can still access the old behavior while keeping up to date. Trust me when I say this will be much easier most of the time than patching old versions with security updates and bug fixes.
That way the land of "n.0.0" versioning lies, where only the major is ever incremented, even for changes that won't affect anyone because the code that would be incompatible hasn't been written yet.
At least for typed languages where an incompatibility clearly caught at compile time is usually preferable over a murky "technically it's still the same API, but..." I'd rather have separate numbers for major incompatibility/minor incompatibility.
Incompatibilities happen, APIs evolve. It's a fact of life. Semver helps communicate those incompatibilities to people who care about it.
If you don't want to freeze your API, then just use 0.X.Y, everyone will understand that they need to do regular maintenance if they want to use your code.
But please, I beg you, do not use version 1+ if you're not planning to keep the compatibility promise.
Well, that's clearly not the way Go operates. Go makes incompatible changes between minor releases; they just don't break type signatures. For example, debug/pe ImportedLibraries(), which is supposed to return all the libraries that a module imports, was stubbed out to return "nil, nil" in a minor release [1]. This is clearly an incompatible change, but as it didn't cause code to fail to compile the Go team deemed it semver compatible.
Edit: Apparently this is wrong! See replies below.
You're not wrong with your point, even if your example is. Go has made many breaking changes that still kept compiling, and compiling seems to be the thing they care about the most. Cribbing from a previous comment I've made... https://news.ycombinator.com/item?id=29763324
1. go 1.9 adding monotonic clock readings in a breaking way, i.e. this program changed output from 1.8 to 1.9: https://go.dev/play/p/Mi6cGCPd0rS
2. net/http started defaulting to http/2 for the same methods, an obviously breaking change
There's a few other things like this littered throughout the go stdlib, and I've personally hit far more breaking changes in Go's stdlib than the "go 1 compatibility promise" would have you expect.
> go 1.9 adding monotonic clock readings in a breaking way, i.e. this program changed output from 1.8 to 1.9: https://go.dev/play/p/Mi6cGCPd0rS
That's not a breaking change, if you were comparing Times with just "==" before 1.9, your program was broken as well:
From the 1.8 source [1]:
// Equal reports whether t and u represent the same time instant.
// Two times can be equal even if they are in different locations.
// For example, 6:00 +0200 CEST and 4:00 UTC are Equal.
// Do not use == with Time values.
func (t Time) Equal(u Time) bool {
return t.sec == u.sec && t.nsec == u.nsec
}
There are many cases where you know that your times are in the same timezone, or you don't mind different timezones comparing as different, and so '==' used to work correctly quite often.
My program was clearly not broken in 1.7, when I followed the documentation "Equal ignores location, == uses location", and things worked well.
My program became counter to their documentation in 1.8. My program broke in 1.9.
Yeah, you're right. I didn't think to check previous versions.
It seems to me like a flaw in the language that "==" can compare unexported struct fields.
Looks like it's possible to disallow comparing structs with "==" at compile time: https://go.dev/play/p/BfM6sDxlTq9. Not ideal, but better than nothing I suppose.
It's worth noting that the author of that article was mistaken, there was likely some other issue with their software than what they described here. ImportedLibraries() in the pe package has never done anything other than returned nil, nil. This wasn't changed in a minor release. You can browse the source history here: https://github.com/golang/go/blame/master/src/debug/pe/file....
For example, debug/pe ImportedLibraries(), which is supposed to return all the libraries that a module imports, was stubbed out to return "nil, nil" in a minor release [1]
I just looked at the Git history and this is plain false. It already looked that way when the big source tree move (src/pkg/ -> src/) was done in 2014. Tracing it back further (to before Go 1.0 times, when there wasn't even a builtin error interface yet and the function returned os.Error), ImportedLibraries was *never* implemented in debug/pe.
I think taking a highly abstract definition of backcompat is not useful. We need a practical definition of back compat. If there are no (or effectively no) downstream consequences of a change, it is clearly backcompat. If there are some downstream consequences, you get into judgment call territory, but it still may be worth it. We cannot create a perfect universal rule here, and Amos is a fool for holding that standard so rigidly.
There's an extra dimension here though - support. Projects don't have unlimited resources which in majority of the cases means that only one major version is live.
For downstream consumers that gives 2 options: get stuck on an old version silently sometimes, or deal with an occasional breakage during usual dependencies updates. If the old version is used for talking to some external service, you will break one day.
However, in case that two versions are so incompatible that both sides of communication channel need to update, then I think it's fair to place responsibility on the service writers to notify their users (through deprecation warnings, documentation, chat, etc.) of the change.
This is the answer. It's all internal, he says in his case. They know what they're doing with their own stuff. Staying on v0 is just another signifier that it's one of their internal things, they need to handle specially.
>> Due to our size, we don’t need any kind of backwards compatibility, we just update everything.
> Then just use 0.y.z versions and be done with it?
FWIW, I also work in an organization that thinks of libraries this way, and we've found success and simplicity in versioning (for production) our Go libraries as 0.X.0 where X is just a monotonically increasing number generated by the build pipeline.
Yep! I think it's fair to needle open source software, but it absolutely makes sense for a lot of internal development to adopt this sort of versioning policy.
Is this a problem inside a small company though? I would expect there to be much better signals about how alpha-ish a library is in that setting (i.e. talking to your coworkers).
Version number hangups are indeed a problem, I don’t mean to suggest my organization has escaped them. But if you can wade through those successfully, the technical solution itself often does make sense.
This is true and unfortunate, but I think the engineering value of meaningful versions is important and I expect that with enough time people will adapt to understanding a different meaning of 0.x.
And besides, people are right to understand 0.x is risky b.c you are not guaranteeing backwards compatibility.
If you expect a certain version to be valuable long term, promote it to Z.0 version. It's OK to to have AwesomeButRapidlyChangingLibrary-2022.03.0 branched from 0.y.z.
- we don’t need any kind of backwards compatibility, we just update everything.
if you don't care about backwards compatibility, then you can stay on v1 forever. Have you considered a monorepo? That would simplify updating packages and give you the behavior you want.
- For the client to update, it’s not a simple path change in go.mod
if a package moves from v1 to v2, there are breaking changes in either api or behavior. I think this implies more than a simple change to go.mod. This also allows importing both versions of a package if necessary.
> So instead off focusing on those changes I have to first fix potential dozens of files for no reason at all.
You don't have to, if you don't want to upgrade to a new major version.
If you do want to upgrade to a major version, which means that there are breaking changes in a package's API or behavior - you sure as hell want to check the correctness of every single line of code written using that package.
Since every file that uses that package must contain an import statement, the import statements are an easily greppable indicator of which files you have to check and potentially fix.
1. You build your project, and it fails in the places where API changed.
IDEs can even pinpoint those locations without building a project
2. You run tests and they fail if the behavior changed
Literally nowhere is "oh, do an automatic search/replace of imports" is a tool for fixing your project or figuring out necessary changes. Except in go, apparently.
Willingness to play russian roulette with major versions may work in small organizations. In large-scale, you can't risk subtle behavior change that still compiles. That's the source of many black magic debugging sessions.
- discovering new major versions in your go.mod (gomajor list)
- switching between major dependency versions. (gomajor get)
- updating the major version of your own module. (gomajor path)
It sounds like Go has implemented package versioning /absolutely right/, this is how things should be.
Two pain points stand out to me in the article though:
> Users of packages aren’t alerted about new major versions
> For the client to update, it’s not a simple path change in go.mod
The tooling should definitely be enhanced to make these things easier to do - probably a flag for -u to update to major versions, and a builtin script to globally update all the imports when a major version has changed.
Or maybe a way to specify a single 'default' major version in one place and then only needing to be explicit when using one that differs from that one. But this means you'd have files that could use different package versions if moved to a different project which could be unexpected.
I recognize that this might be a PITA, but for me there is no easy way out of problems related to major versioning, and the clear answer is sticking with version 0.x.x for a considerate amount of time.
It seems people rush to publish version 1.0 (or 2.0, etc.) of their libraries, when they'd be better off just sticking with version 0.x.
It's not like that a package isn't ready for production because it is < 1.0. It might as well be. If you're an early adopter, I'd say that is even welcome: you're aware that its API might chance, that its quite new, etc. It gives more confidence than a package with version 7.x (at least in informing you that it's prone to changes, and allowing you to make an informed choice), IMHO.
Lots of teams, companies, and general best practice all have policies and/or guidelines around versioning, and its usually something to the effect of wait till the package is 1.0 to use it in production. I think that's why there is always a "race" to the 1.0
I think we should just push for more date based versioning, for instance, CalVer[0]
All of my Go packages are CalVer v0.year.increment because I'm just a lone developer, so nothing I release can be v1. To be an actual v1, there needs to be a team of people who are committed to keeping a package going indefinitely. I understand why people want to use v1, because it sounds cool and stable, but realistically if you are just releasing code on your own, it is not and cannot be v1.
Because v1 is a social commitment to keeping the API stable and the library working for the foreseeable future. If you don’t want to make that commitment, there’s always v0, which I do in fact use.
Semantic versioning serves a purpose - it makes it possible to automatically update dependencies to the latest compatible version without breaking code.
If you don't need automatic updates, then any kind of versioning is fine. Hell, you could get away with just using a single incrementing integer. v1, v2, v3, ..., v225883, etc.
That's a terrible purpose, and SemVer does no such thing. There are approximately 0 large libraries that actually offer SemVer style guarantees. For example, the Go stdlib itself is definitely not using SemVer, as it often introduces breaking changes between minor versions.
The only thing loose dependencies and automatic version resolution and duplicating libraries achieve is a false sense of security when upgrading code. And the only thing Go's awful, awful idea to force v2+ to change import path is to make a mess in your version control history, and hide the important bits in a sea of needlessly changed files.
Semver isn't designed for this, and this whole idea of automatically updating dependencies is awful. Don't do it. Use discretion when updating anything.
My biggest pain with Go modules has been the fact that projects use 0ver [0] and some are backwards compatible updates and others aren’t. But it was designed in a way to force everyone to use semver.
It gets worse if you have a mono-repo because OSS projects may depend on a module which depends on a module which uses 0ver but minor versions are backwards incompatible. This means that dependency conflicts become a nasty situation.
Then you get into opinion-based world where: - everyone should just use semver properly. - everyone shouldn’t use v0 for production. Or variations on this where some are production-ready and others aren’t. - everyone should use v0 and only have backwards compatible updates. Then fork if you have an backwards incompatible update.
Feels like everyone loses with how this works today.
[0] https://0ver.org
I’ve taken to just making /v0 or /v1 the root of every package I maintain. Even though it’s not required I think it’s a good practice.
It doesn’t affect the package name (unless you choose to), only the import path. So it eliminates any ambiguity about the interface you intend to use and it only affects import statements. Personally I think that’s a good balance.
I like Go but Go has made some serious misteps (IMHO), which the author touches on. Dependency management in Go is so incredibly bad it's hard to fathom. Did no one on the early design team ever deal with depenencies? Java is so much better here and came years earlier. Go would've been so much better had they just copied any of the Java options from Day One.
So this versioning thing is just weird and I agree with the author. It's a strange thing to have opinions on and an even stranger opinion to have on that thing.
But the thing that gets me is the whole putting "github.com/username/module" into code is just awful.
I disagree with all of this. Go's package management system is a breath of fresh air compared to all other languages. Nobody ever has any problem grabbing dependencies. There is only one way to do it and it's built into the language runtime; if you have Go installed you can install the modules. Other applications on your system can, with no effort (virtualenv, etc.) use different versions of those modules. Dependency installation can't print messages to my terminal.
It's a joy.
Importing modules from a particular URL is a great way to handle them. No central server required! Everyone can name their module "utility utils" or whatever. And, nobody is forcing you to use Github to host your modules, you can put them on any web server you control and import them as example.com/your-thing. Decentralized. Easy to use.
If I had two complaints, they would be:
1) Library authors that think "replace" directives propagate up to consumers. They do not. They are ignored when you depend on the module. If you depend on one of these libraries and want the workaround that the author has smoothed over with a "replace" directive, you have to copy that directive to your own go.mod.
2) Library authors that distribute one go module for their super-complicated server and their super-simple client. This results in unnecessary dependency explosion. (Loki 1.x was an example of this pattern. Their server depends on things like Kubernetes, where their client only depends on net/http. But if you naively import their client, suddenly your project depends on Kubernetes.)
(2a would be no concept of test-only modules. Some modules are only needed for the tests; it would be nice to not propagate these up to consumers. It isn't an actual problem, though, just a "would be kind of nice" or "I could see why they did that" if it existed.)
Neither of these are Go issues, just maintainer issues that can happen with any language.
Everything on the Internet is vulnerable to this. NPM has gone down. You can send NPM a legal request to delete a module. Your domain registrar can take your domain name. Your local IP address registry can take away your IP address. At the end of the day, you can vendor all your modules and keep them with your source code if this worries you. It's not really in scope for a programming language to make a completely immutable and takedown-proof Internet just to let Internet users share some code with each other. But, they've done a pretty good job here.
Every non-tiny place I have worked finds it necessary to copy known working versions of code they rely on, to an internal server and build from there. You don't have reproducible builds, for example, from a remote sever (maybe there are hashes?). But even then you don't have reliable access to external servers (they may crash, be bought, be hacked, change business models). So people self-host what they use.
Can you do this in Go, without changing source code?
Not related to above, your 2) reminds me of my pet peeve: doc dependencies included in module dependencies. Oh, you use Sphinx, we'll install that. Oh, Sphinx needs Python, we'll install that. Try to use a simple, non-Python, library, get Python and all it's dependencies.
Maven naming convention is absolutely atrocious. And don't tell me that naming convention is optional. To be part of Java ecosystem Maven naming convention is a requirement.
> Dependency management in Go is so incredibly bad it's hard to fathom.
It used to be pretty terrible. Today it's at least as good as npm.
> Did no one on the early design team ever deal with depenencies?
It was developed at Google and they have a giant monorepo, associated infrastructure, as well as company policies against multiple versions of dependencies. They also vendor external deps into this repo. So no, Google deals with dependencies a lot, but they do things very, very differently from almost everyone else.
What if I publish gitlab.com/username/module and i dont know anything about your github project? This seems like a pretty obvious disadvantage, so its surprising you failed to see it. What are the advantages?
I would take this further, simply `import "module"` would be nicer. (I don't care about anyone's github username, and I often need to fork projects and today that means mindless search and replace)
Conflict resolution within the same module could be handled in the go.mod file:
I develop experimental software where backwards compatibility is the least of my concerns.
To avoid the SemVer nonsense imposed by Go and NPM, I simply name every version to be 0.0.yyyymmdd derived from commit date.
If Ubuntu and DPDK can name their versions by date, why can't I?
Let's say you write a Go program using a dependency "github.com/foo/bar" v1.0 and post it on the Internet as a regular file (or multiple files), without using git. Someone else downloads the program and runs the usual Go commands:
go mod init example.com/program
go mod tidy
go build
The go tool will see the "github.com/foo/bar" path in the import statements, download the code from the repository, compile and everything will work.
Now, let's say the "github.com/foo/bar" module gets a backwards-incompatible change, but does not change the import path, and you attempt to do the above process again. This time, the go tool will download the incompatible version of "github.com/foo/bar" and the build will fail - or even worse, succeed but have some logic bugs that will go unnoticed until some massive shit happens.
So the Go project used a lazy solution to a hard problem (packages are git repositories without even a ref as version) instead of, say, having an actual versioning scheme, therefore you get to implement this by hand using nonsense logic.
Actually, Go used a very elegant solution to a hard problem of modularization: directories. A directory == a package. Import path specifies a path to the directory that contains a package.
A package may, or may not, decide to have a stable API and document it. If it does, and it commits to the version 1.0, then it basically gives a promise: "This package will not change its API in a way that will break correctness of currently-correct programs that use it".
Since a package == a directory, if you want to keep package compatibility, you must not change the contents of the import directory. Therefore, you need to create a new one, preferably called v2/, to put the new code in.
It’s an elegant solution for packages that don’t use versioning features like git tags. For every other package out there that uses git tags, it’s just a lazy solution: why on earth would I want to specify the version in the path? Git tags solves that problem in a more elegant way (both from the side of the maintainers and users).
Git tags are completely orthogonal. When your main branch starts being v2, you only have to change go.mod to tack a v2 onto the end and keep your source code in the same place. "go get example.com/you/v2" will fetch exmaple.com/you from the main branch, read the go.mod file to see that it's v2, and continue. You don't have to actually create a v2 directory, or keep your old version in the main branch. (If you do tag releases, then "go get example.com/you/v2@v1.2.3" won't work, though.)
> it’s just a lazy solution: why on earth would I want to specify the version in the path?
It's not a lazy solution, just opinionated.
Personally, I love the fact that just by seeing the import path I know exactly which codebase is used. I don't have to open my go.mod, see which version I'm using, clone the repository, dig through the history to check out a specific tag and see what code is actually used in my program... I just open the repo in my browser and browse through the directories. There isn't a single system on earth that doesn't support directories!
I also love the fact that I don't have to know s**t about git to use Go. If Go was to suddenly switch to git tags, not only would it break a massive amounts of existing code, but would basically force everyone to learn about git tags just to be able to see what code are they using, which would raise the amount of paperwork I have to fill in order to work on the thing I care about. Go is fundamentally against needless paperwork.
> Personally, I love the fact that just by seeing the import path I know exactly which codebase is used.
This is a bizarre statement. You only care to look at v4 or v7, not nor whether you're looking at v7.5.9 that is actually used in your program, or at v7.6.4 which you never upgraded to?
> I also love the fact that I don't have to know s*t about git to use Go.
Go is actually about the only language that forces you to know Git (or other sccs) to do dependency management. Just look at how horrible it is to develop and release multiple Go modules from the same repository, requiring specific tag names and who knows what else.
Go mod is a terrible kludge and virtually every decision they've taken is simple only for the Go implementation team.
> I just open the repo in my browser and browse through the directories.
Browse via what? It would be entirely reasonable, not weird at all, if git.example/user/repo/ showed a list of branches and tags. And then it would actually be easier to reach git.example/user/repo/v3/ than to reach git.example/user/repo/master/v3/
The particular way github does directories isn't canonical. And if you wanted to clone the repo, you could clone a specific branch easily.
When it comes to minor versions, you have to dig anyway to see the matching source under the current system.
Via whatever tool you have available. Most projects also have a download link or a clone command written, in case the remote repo doesn't support in-browser file listing, so you can download it and browse locally, without needing to checkout tag or whatever.
> if you wanted to clone the repo, you could clone a specific branch easily.
Unless I don't know how to clone a specific branch - in which case I need to learn about git branches, figure out how they work, and how to clone a specific branch, and how to go back to master etc. etc. etc.
In contrast to just cloning the repo and browsing the v2/ directory.
You blew past my main point, which is that if you go to browse an entire repo online, there's no reason that a v2/ directory will be easier to find than a v2/ branch, unless you're specifically assuming the site looks like github.
> In contrast to just cloning the repo and browsing the v2/ directory.
"just" cloning the repo? You gave this list of arduous steps for why cloning a tag is harder than browsing, but "just" cloning the repo still makes you do all but one of those steps!
And if something is generating the clone command for you, it can add "-b".
You don't need to know how git branches work. You definitely don't need to know how to go back to master.
> You blew past my main point, which is that if you go to browse an entire repo online, there's no reason that a v2/ directory will be easier to find than a v2/ branch
Absolutely not true. If you can "browse a repo", then by definition you can browse a directory inside a repo, so by definition you CAN do it. The option to choose a branch may or may not be there.
> "just" cloning the repo? You gave this list of arduous steps for why cloning a tag is harder than browsing, but "just" cloning the repo still makes you do all but one of those steps!
When you install a package from a remote repository, the go tool clones it to ~/go/pkg/mod/, so you can open it locally and browse through the code, without even touching git. So yeah, it is "just" cloning the repo. Also it works with any popular VCS, not just git.
> Absolutely not true. If you can "browse a repo", then by definition you can browse a directory inside a repo, so by definition you CAN do it. The option to choose a branch may or may not be there.
Again, you're assuming that it shows master first.
If it shows the list of branches first, then it's easier to find a branch than it is to find a subdirectory.
Both are equally valid ways to show a git repo.
> When you install a package from a remote repository, the go tool clones it to ~/go/pkg/mod/, so you can open it locally and browse through the code, without even touching git. So yeah, it is "just" cloning the repo. Also it works with any popular VCS, not just git.
Nothing says it has to use that method. The other way could be just as streamlined, if that was the layout people wanted to use.
I'm very confused. Go literally uses refs as versions. However to allow for situations where you'll need to update a module because of a bug fix or something like that, Go allows you to safely automatically update to the latest minor version, on the assumption that there are no API breaking changes. If there is such a change, Go wants the module owner to increment the major version so you don't get bugs just from updating.
I will agree that the ergonomics of this method aren't perfect by any means. But what it does accomplish, is that it forces you to declare your dependency explicitly (which seems to be one of Go's underlying principles).
> Users actually need to grep through their codebase and change each import statement to point to the new major version
You also need to do this if the url for the package (for example if the repo changes to a different hosting service,or is moved to a different organization), or if you want to build with a fork at a different url.
I just keep all my go project major versions at 1, then use the second number as "major" and the third number as "minor". Anything incompatible increments major, anything compatible increments minor.
This avoids all that v2, v3, v4 "nanny knows best" nonsense.
Your users are going to have a bad time if they ever import two different libraries that both depend on different fake "major" versions of your library. The newer of the two will be selected, and any incompatibility that the different libraries are not prepared to handle will break things.
If you're not distributing your Go package to others as a library, then you don't need to follow the Go module versions. They are only enforced user-side.
The idea is sound. A major version incompatibility means the library is a different library. In the Java world, Apache Commons Collections does this. There it is helpful since you can then have multiple versions of Collections in a single program.
In the Golang case, encoding this is smart, especially since Golang's structural typing makes it easier to use. However, there is an ergonomics problem. I knew of all these things and I still had trouble upgrading a Golang FUSE driver recently. It's worth keeping in mind that I don't write code as a matter of course.
> This means major version changes are a fairly regular occurrence. Some say that we should just stay on v0, and that’s a reasonable solution. The problem is these ARE production packages that are being used by a wide number of services. We want to Semver.
This is a lame problem. v0.Major.Minor-Patch. Done. Yes, semver includes an optional dash for a fourth parameter, and Go supports it.
How does the forth parameter affect version ordering though? Since its a pre-release I would assume v0.1.2-3 actually comes before v0.1.2. You'd have to ensure make all your versions have the dash I guess.
> Since its a pre-release I would assume v0.1.2-3 actually comes before v0.1.2.
That's correct.
(The really fun weird corner of semver is build suffixes. According to semver 2.0.0, v1.2.3+4 and v1.2.3+5 have no specified relative ordering. According to semver 2.0.0-rc.1, build suffixes are ordered.)
> In fact, we use Go on the front and backend at Qvault, and we’ve found that it’s wonderful to have standardized formatting, vetting, and testing across the entire Go ecosystem
Well, that doesn't seem to be true as Qvault is clearly a Wordpress powered website.
> this is redundant because the local go.mod file already has the semantic version of all dependencies tracked
The whole point of having full version path in each file is to allow gradual upgrade to a new major version/a completely different package. Listen to Russ Cox go over this many times.
> Users of packages aren’t alerted about new major versions
Fair point.
> For the client to update, it’s not a simple path change in go.mod
> Maintainers should be able to increment the major version via Git tags.
Go is not tied to git or any other single SCM system. They can however be pragmatic about this.
> I get why these rules exist, and I think they are great for large open projects
You either don't, or you think that smaller/trivial project's requirements somehow take priority over larger ones.
There are plenty of toy/application specific/scripting programming languages built to be used to write a few hundred lines or glue things together. Go specifically and repeatedly states that it's aimed at large, distributed and long term projects with lots of changes over time and many contributors. You know that going in. So you're criticizing (poorly I might add) the very notions that this language is built upon! This is like criticizing an industrial kitchen equipment for not having pretty colors and curvy shapes to match your home decor.
> The whole point of having full version path in each file is to allow gradual upgrade to a new major version/a completely different package. Listen to Russ Cox go over this many times.
That only works if v2 of the library is crazy enough to keep all of the v1 apis around as well. Otherwise, when I switch version the old import path stops working.
> That only works if v2 of the library is crazy enough to keep all of the v1 apis around as well. Otherwise, when I switch version the old import path stops working.
Gradual upgrade, as in you can update a single file to use the next major version. Not sure what you’re on about.
> hundreds of files which needlessly got changed
If you have hundreds of files and upgrading to a new major version all at once, you’re creating the exact risk that Go is trying to mitigate.
Kinda useless article. Maybe need to think why they need so many major versions so often to break backward compatibility. And if one just updates minor versions none of those problems occur.
That is not possible. You get no usage data if someone uses your open source library, and the maintainer of the library that depends on you could be dead. This is a fine approach when you mail your API users an invoice every month, just tack on "hey start using v2". But open source libraries don't have that invoice cycle. The most you can do is tack on a warning in a future patch version, but they would have to update to that first.
Maybe on a small scale breaking changes don't cause issues, but in large-scale development, you simply can't afford to have a bunch of random CI/CD pipelines fail because a developer decided to "accommodate for" a backwards-incompatible change without separating it to a new import path.
>Using different paths for different major versions makes more sense in situations where we may require two different versions of the same package, you know, diamond imports and all that. This is the exception, not the rule, and it seems strange to tap dance around a problem that doesn’t exist in most codebases.
Diamond imports are not as rare as you think - it's just that you never run into it in your own code, but in the code of your dependencies. And in Go, the problem is already solved for you so you never even see the solution at work.
Let's say you use two libraries for, say, backend and frontend - libbackend and libfrontend. Let's say they both depend on some parser library, say libparser, version 1.0.
Now assume that libbackend upgrades the libparser dependency to 2.0, but the import path stays the same and your application - the one that has been successfully auto-updated on a CI/CD server for years and nobody remembers how it works anymore - breaks, and you have no idea why, compiler reports some weird API-incompatibility errors, and now you have to spend two weeks getting back into the codebase and debugging the issue, until you realize you can't really fix it, because the guy developing the language decided that diamond imports are "a problem that doesn’t exist in most codebases".