Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

> There is no way for changes in the outside world—such as a new version of a dependency being published—to automatically affect a Go build.

> Unlike most other package managers files, Go modules don’t have a separate list of constraints and a lock file pinning specific versions. The version of every dependency contributing to any Go build is fully determined by the go.mod file of the main module.

I don't know if this was intentional on the author's part, but this reads to me like it's implying that with package managers that do use lockfiles a new version of a dependency can automatically affect a build.

The purpose of a lockfile is to make that false. If you have a valid lockfile, then fetching dependencies is 100% deterministic across machines and the existence of new versions of a package will not affect the build.

It is true that most package managers will automatically update a lockfile if it's incomplete instead of failing with an error. That's a different behavior from Go where it fails if the go.mod is incomplete. I suspect in practice this UX choice doesn't make much of a difference. If you're running CI with an incomplete lockfile, you've already gotten yourself into a weird state. It implies you have committed a dependency change without actually testing it, or that you tested it locally and then went out of your way to discard the lockfile changes.

Either way, I don't see what this has to do with lockfiles as a concept. Unless I'm missing something, go.mod files are lockfiles.



There are some subtleties here, but go.mod files are not lockfiles, including go.mod files don't follow a traditional constraint/lockfile split, which I think is part of the point in that snippet you quoted.

Another way they differ is when installing a top-level tool by default npm does not use the exact version from a library's lockfile to pick the selected version of another library as far I as understand, whereas Go does by default use the exact version required by a library's go.mod file in that scenario.

In other words, go.mod files play a bigger role for libraries than a traditional lockfile does by default for libraries in most other ecosystems.

Here's a good analysis on the contrast between go.mod and the default behavior of more traditional lockfiles (using the npm 'colors' incident as a motivating example):

https://research.swtch.com/npm-colors

That link also includes some comments on 'npm ci' and 'shrinkwrap' that I won't repeat here.

All that said, go.mod files do record precise dependency requirements and provide reproducible builds, so it's possible to draw some analogies between go.mod & lockfiles if you want. I just wouldn't say "go.mod files are lockfiles". ;-)


Wouldn't build size increase a lot if transitive dependencies were pinned to direct dependency lockfiles? Like if library A says "use version 1.0.0 of library X" and library B says "use version 1.0.1 of library X", then you'd likely end up bundling duplicate code in your build.

Not saying the tradeoff isn't worth it, but pinning to dependency lockfiles isn't without downsides.


FWIW, that's not what Go does. In your scenario, a Go binary ends up with a single copy of library X -- the 1.0.1 version. That's because library A is stating "I require at least v1.0.0 of X", and library B is stating "I require at least v1.0.1 of X". The minimal version that satisfies both of those requirements is v1.0.1, and that's what ends up in the binary.

That behavior is Go's "Minimal Version Selection" or "MVS". There are many longer descriptions out there, but a concise graphical description I saw recently and like is:

https://encore.dev/guide/go.mod

That's the default behavior, but a human can ask for other versions. For example, a consumer of A and B could do 'go get X@latest', or edit their own go.mod file to require X v1.2.3, or do 'go get -u ./...' to update all their direct and indirect dependencies, which would include X in this case, etc.


Continuing that example -- in Go you end up with v1.0.1 of X by default even if v1.0.2 is the latest version of X.

That is a difference with many other package managers that can default to using the latest v1.0.2 of X (even if v1.0.2 was just published) when doing something like installing a command line tool. That default behavior is part of how people installing the 'aws-sdk' tool on a Saturday started immediately experiencing bad behavior due to the deliberate 'colors' npm package sabotage that happened that same Saturday.

In any event, it's certainly reasonable to debate pros and cons of different approaches. I'm mainly trying to clarify the actual behavior & differences.


What if the requirement was pinned specifically to 1.0.0 in order to avoid a bug introduced in 1.0.1. With a package that also requires a minimum 1.0.1, that should be unresolvable set of requirements and your package manager should fail to make a lockfile out of it.


how are you not describing package.json right now what is the difference


The difference is the npm ecosystem actively encourages automatically following SemVer because by default it uses a ^ to prefix the version number. https://heynode.com/tutorial/how-use-semantic-versioning-npm...

The practice of dependencies’ dependencies being specified using SemVer version constraints to auto-accept minor or patch changes is the difference compared to Go, and why lockfiles will not always save you in the npm ecosystem. That said, approaches like Yarn zero-install can make very explicit the versions installed because they are distributed with the source. Similarly, the default of using npm install is bad because it will update lockfiles, you have to use npm ci or npm install —ci both of which are less well-known.

So it’s not impossible to fix, just a bad choice of defaults for an ecosystem of packages that has security implications about the same as updating your Go (or JS) dependencies automatically and not checking the changes first as part of code review. Blindly following SemVer to update dependencies is bad, from a security perspective, regardless of why or how you’re doing it.


> The difference is the npm ecosystem actively encourages automatically following SemVer because by default it uses a ^ to prefix the version number.

So does Go. In fact, Go only supports the equivalent of ^, there is no way to specify a dependency as '=1.2.3'. That is, whenever you have two different dependencies which use the same dependency at different (semver compatible) versions, go mod will always download the newer of the two, effectively assuming that the one depending on an older version will also work with the newer.

The only difference in this respect compared to NPM (and perhaps also Cargo or NuGet? I don't know) is that Go will never download a version that is not explicitly specified in some go.mod file - which is indeed a much better policy.


It’s very subtle, but there are some important differences. For example, lockfiles are not recursive in NPM: the NPM package (usually?) does not contain the lockfile and does not adhere to it when installed as a dependency. It will pick the newest version of dependencies that matches the spec in package.json.

Go mod files are used recursively, and rather than try to pick the newest possible version, it will go with the oldest version.

This avoids the node-ipc issue entirely, at least until you update the go.mod.


This really depends on the specific package manager: if you're building an application in Rust, its lockfile will contain the full tree of dependencies, locked to a specific version.


I might be misunderstanding GP, but I think what they're saying is that when package A depends on package B, building package A will use B's lockfile. Assuming that's the case, I think this is generally not how Rust does things, as by default Cargo.lock is explicitly listed in the .gitignore when making a library crate, although there's nothing stopping anyone from just removing that line. I think I remember reading in documentation somewhere that checking in Cargo.lock for libraries is discouraged (hence the policy), but I don't recall exactly where since it's been so long. (That being said, there's a pretty decent chance you were the one who wrote that documentation, so maybe you might remember!)


> I think this is generally not how Rust does things

That is correct.

> as by default Cargo.lock is explicitly listed in the .gitignore when making a library crate

Even if it is included in the contents of the package, Cargo will not use it for the purpose of resolution.

The "don't check it in" thing is related, but not because it will be used if it's included. It's because of the opposite; that way new people who download your package to hack on it will get their own, possibly different Cargo.lock, so you end up testing more versions naturally. Some people dislike this recommendation and include theirs in the package, but that never affects resolution behavior.


Ah, good to know! I'm glad I asked


Yes, this is true; I think the article is definitely making the distinction vs NPM and not Rust.


The article seemed to go out of its way not to mention any specific package manager or ecosystem. So I think comparing to Rust is completely reasonable.


It didn’t mention one by name, but Rust hasn’t been subject to any widely publicized supply chain attacks. They do, however, mention left-pad by name. I think it can be implied that they really did just mean npm.


They definitely should have said that then. Simply referring to "lockfiles" paints with a very broad brush that includes a number of package managers that don't have the problems that NPM does.


The default way Go handles go.mod is fairly different than the default way Cargo handles Cargo.lock files, including for example with libraries.

Also, when the blog says:

> Moreover, when a dependency is added with go get, its transitive dependencies are added at the version specified in the dependency’s go.mod file, not at their latest versions, thanks to Minimal version selection.

I believe that is significantly different than default Cargo behavior and for example default 'pub' behavior for Flutter (though I know approximately nothing about Flutter package management beyond a cursory search just now ;-)

To my knowledge, both Cargo and Flutter 'pub' prefer the most recent / highest allowed version by default when asked to solve constraints, whereas Go does not.

Cargo: [1]

> When multiple packages specify a dependency for a common package, the resolver attempts to ensure that they use the same version of that common package, as long as they are within a SemVer compatibility range. It also attempts to use the greatest version currently available within that compatibility range.

Flutter 'pub': [2]

> For each package in the graph, pub looks at everything that depends on it. It gathers together all of their version constraints and tries to simultaneously solve them. (Basically, it intersects their ranges.) Then it looks at the actual versions that have been released for that package and selects the best (most recent) one that meets all of those constraints.

[1]: https://doc.rust-lang.org/cargo/reference/resolver.html

[2]: https://dart.dev/tools/pub/versioning#constraint-solving


In that same section, the blog describes the behavior of 'go install foo@latest' and contrasts it to how the default install "in some ecosystems bypass pinning."

That is also a difference in default behavior between Go and Cargo.

To install a 'foo' binary, 'go install foo@latest' gives you the latest version of foo, but the direct and indirect dependencies used are the versions listed in foo's go.mod or a dependency’s go.mod file (and not whatever the latest versions of those direct and indirect dependencies might be at the moment the install is invoked).

'cargo install foo' supports the optional --locked flag, but its not the default behavior: [1]

> By default, the Cargo.lock file that is included with the package will be ignored. This means that Cargo will recompute which versions of dependencies to use, possibly using newer versions that have been released since the package was published. The --locked flag can be used to force Cargo to use the packaged Cargo.lock file if it is available.

There are definitely pros and cons here, but to my knowledge it is not "just NPM" that is being contrasted in the blog.

Finally, I'm no world-class Rust expert, but I like using Cargo. I think Cargo is a fantastic tool that set the bar for package mangers, and it has done great things for the Rust community. But it is easier for communities to learn from each other with a base understanding of where & why different choices have been made, which is part of what is behind some of my comments around Go's behavior. ;-)

[1]: https://doc.rust-lang.org/cargo/commands/cargo-install.html


So, I believe that you're confusing two different cases here.

`cargo install` is not how you add a dependency, it's not like `npm install`. `cargo install` downloads the source for and installs a runnable binary program, like `npm install -g`. Cargo does not currently have an "add this dependency to my Cargo.toml" command built-in, but `cargo add` is coming from this purpose.

With `cargo install`, the default behavior is to completely recompute the dependency tree before doing the build. The `--locked` flag modifies that to use the lockfile included in the package to do that build instead (and in fact fail compilation if it does not exist). That lockfile will still be a full graph of all dependencies and transitive dependencies to build the binary, it doesn't like, recurse or use any lockfiles that exist in any of the dependencies' packages.


Hi Steve, first, thanks for weighing in here!

I might have misunderstood your comment, but in my GP comment I was indeed attempting to contrast 'go install foo@latest' with 'cargo install foo', which both install binaries. (I wasn't talking about 'go get bar@latest', which now is just for updating or adding dependencies to a project).

Also, I'm contrasting what happens by default at the moment either binary install command is run. My understanding is Cargo's (non-default) 'cargo install --locked foo' behavior is similar to the default behavior of 'go install foo@latest'. In other words, the default behavior is fairly different between 'cargo install foo' (without --locked) vs. 'go install foo@latest'.

I edited my GP comment to simplify the example to use 'foo' in both cases. Maybe that helps?


Ah yes, I did miss that, thank you / sorry :) Too many sub-threads around here!

I don't know go install's semantics well enough to know if that comparison is true or not, I'm just trying to make sure that Cargo's semantics are clear :)


This is, roughly, what "go install" does:

It will copy a binary to $GOBIN. If the binary is not built, it will be built from source. If the source is not available on the local system, it will be fetched.

During the build, any dependencies of the build target not available of the local system will be fetched.


I believe Cargo does still have less strictness over dep versions than Go modules, since it will never use a module newer than the one specified in any go.mod file. Lockfiles are generally not honored recursively, and I don’t think Cargo is different here? Hope I’m not spreading misinformation, though I couldn’t find any docs with a cursory glance.

I don’t want to make assertions that I’m less sure of, but I think NPM and Cargo are actually more similar than different here. They both specify exact versions in lock files, for all nested dependencies, but don’t honor the lock files present inside dependencies, instead calculating the nested deps from the constraints.


Cargo does not do "recursive lockfiles", that's correct.


This is also true of npm and yarn, as far as I can tell: package-lock.json and yarn.lock contain the exact version of every transitive dependency.


I always forget the exact semantics, but the parent's description of them as "recursive" is not the same as Cargo; Cargo determines the full tree and writes out its own lockfile, if dependencies happen to have a Cargo.lock inside the package, it's ignored, not used.


How does go deal with the diamond dependency issue when transitively you've a dep on the same package via two different paths?


As far as I understand it... the import path that you use to import a package acts as its identity, and only one version of any given package will be installed. The way that it will determine this is by choosing the lowest version specified in any package that depends on a given package. Major versions of packages are required to have different import paths with Go modules, so when depending on two different major versions of the same package, they are treated effectively as their own package.


I think you might have a small typo here:

> by choosing the lowest version specified in any package that depends on a given package

It picks the highest version specified in any of the requirements. (That's the minimal version that simultaneously satisfies each individual requirement, where each individual requirement is saying "I require vX.Y.Z or higher". So if A requires Foo v1.2.3 and B requires Foo v1.2.4, v1.2.4 is selected as the minimal version that satisfies both A and B, and that's true even if v1.2.5 exists).


Yep, I regrettably described the system wrong. It’s the highest version specified, and thus minimum version possible.


That's a nice & concise way to describe it.


Minimalistic Version Selection might have been a better term for it.

That did confuse me a lot. Selecting the maximum makes a lot more sense, since that gets SemVer assumptions correct.


Okay but if I depend on A and A depends on C and has it pinned at 1.5, but I also depend on B and B has C pinned on 1.8, then I get 1.5? But what happens if C is on 1.8 because it doesn't work with 1.5 because an API it needs doesn't exist in 1.5?

Are we not talking about the transitively pinned dependencies in the "lock" section, or are we talking about logical constraints?

Logical constraints would make more sense, but if the constraints on C across various transitive deps are > 1.5 and > 1.8, and 1.9 or 1.10 exists, I probably want the last version of those.


> Okay but if I depend on A and A depends on C and has it pinned at 1.5, but I also depend on B and B has C pinned on 1.8, then I get 1.5?

No, you end up with the highest explicitly required version. So 1.8 in that scenario, if I followed. (Requiring 1.5 is declaring support for "1.5 or higher". Requiring 1.8 is declaring support for "1.8 or higher". 1.8 satisfies both of those requirements).

> if the constraints on C across various transitive deps are > 1.5 and > 1.8, and 1.9 or 1.10 exists, I probably want the last version of those.

By default, you get 1.8 (for reasons outlined upthread and in the blog post & related links), but you have the option of getting the latest version of C at any time of your choosing (e.g., 'go get C@latest', or 'go get -u ./...' to get latest versions of all dependencies, and so on).

Also, you are using the word "pin". The way it works is that the top-level module in a build has the option to force a particular version of any direct or indirect dependency, but intermediate modules in a build cannot. So as the author of the top-level module, you could force a version of C if you needed to, but for example your dependency B cannot "pin" C in your build.


I'm fairly sure Go Modules does not support what you’re describing. It specifically avoided having a SAT solver (or something similar), unlike most package managers. You specify a minimum version, and that’s it. 1.8 would be selected because it is the highest minimum version out of the options 1.5 and 1.8 that the dependencies require. Unless you edit your go.mod file to require an even higher version, which is an option. Alternatively, you can always replace that transitive dependency with your own fork that fixes the problems, using a “replace” directive in your go.mod file.

If your dependencies are as broken as you’re describing, you’re in for a world of hurt no matter the solution. I also can't remember ever encountering that situation.


Well after 12 years of using ruby and bundler and maintaining a bundler-ish depsolver at work, and playing around a bit with cargo, I can say that it is becoming clearer as to why I don't grok go modules at all.

The lack of a depsolver is a curious choice...

I don't think my example was remotely "broken" at all, that's just another day doing software development.


> I don't think my example was remotely "broken" at all, that's just another day doing software development.

It's not the norm for me. If this is what you consider to be the norm, then this kind of statement doesn't make me feel any better about Ruby.

I will say that Bundler is one of the better package managers, but the existence of the constraint solver doesn't fix this problem -- Bundler doesn't allow you to have multiple versions of a single dependency. The problem is fundamentally the dependency not maintaining its compatibility guarantees, which I would definitely call "broken". Sometimes breakage is unavoidable, like with security fixes that you want to be available even to users of existing SemVer versions, but it should not be a common situation.


If I'm understanding you properly, recursive lockfiles means that if I depend on some chain of dependencies A->B->C->D->E, and E has a security vulnerability that they patch in a new version, I have to wait for A B C D and E to all update their lockfiles before the security vulnerability will be patched on my system?


That's not correct. You can unilaterally decide to update the version of E without waiting for anyone. Alternatively, if only C for example decides to update their required version of E, you would get that version of E if you updated your version of C (directly or indirectly), without needing to directly do anything with E yourself.

Slightly longer explanation of the mechanics here: https://news.ycombinator.com/item?id=30871730

The best complete explanation is probably here: https://research.swtch.com/vgo-principles


But wouldn't that have the same issue then? Developers decide to update their dependencies to patch any security vulnerabilities, and wind up adding installing node-ipc's malicious update


The difference is that it's an explicit choice instead of other package managers who'd happily install latest compromised versions of packages by default.


But if its a manual explicit choice, that adds more friction to these patches, and many developers may not update them at all. It's a trade off


A manual choice can be easily automated; an automatic choice can be difficult-to-impossible to de-automate.


It is trivial to manually upgrade dependencies in NPM. You just use `npm update <package>` with an optional version number if you want. And upgrading all dependencies of a Go package is also a single command. So honestly it seems like there is very little difference. My main point here is the trade-off. Either you reduce the friction for upgrades, and run the risk of malicious upgrades like node-ipc. Or you increase the friction, and run the risk of security vulnerabilities being unpatched in many projects.

I personally prefer the former. Encourage upgrades, but then NPM should also have a separate repository for community verified / trusted packages to reduce the chance of a random single developer damaging the entire ecosystem (left-pad, node-ipc, etc)


If I set up a new Node project I get the highest 'supported' version of whatever. If I add a new dependency I get the latest version of any transitive dependency I didn't already have. As far as I know that's impossible to disable. That's the automated upgrade I mean.


I see, in that case yes Go does have more tooling for being able to install the minimum vs the latest of all packages, using their `update` command if you want the latest. But it would also be trivial for Node to add a command to grab the minimum of all dependencies when installing new packages. They just haven't felt the need to add such a feature. Because again, it comes down to which side you want to encourage: installing minimum versions to prevent malicious updates, or installing latest to patch security vulnerabilities.


Perhaps

"A module may have a text file named go.sum in its root directory, alongside its go.mod file. The go.sum file contains cryptographic hashes of the module’s direct and indirect dependencies."

And

"If the go.sum file is not present, or if it doesn’t contain a hash for the downloaded file, the go command may verify the hash using the checksum database, a global source of hashes for publicly available modules."

Should be stressed on. If I committed a dependency version (go.mod) and checksum (go.sum) along with the code, either I get a repeatable build everywhere, or build fails if dependency not found or found to be modified.

I am not sure if all other package managers include checksum with dependency version.


> the go command may verify the hash

If we're talking about reproducible builds, the word "may" seems concerning here?


I suspect the primary purpose of the word "may" in that sentence is that you can choose to disable checking the hash against the Certificate Transparency style https://sum.golang.org. In other words, you can opt out. If you do, you fall back to your local go.sum file, which is more-or-less a "TOFU" security model: https://en.wikipedia.org/wiki/Trust_on_first_use

More on sum.golang.org: https://go.googlesource.com/proposal/+/master/design/25530-s...


Thank you for the clarification!


Discussing "what is a lockfile" is a bit of a headache because different languages have different files which do different things. Generally speaking, there's some file which specifies the dependency versions and some file with cryptographic checksums of the all transitive dependencies.

In Go it's go.mod / go.sum. In NPM, it's package.json / package-lock.json. In Rust it's Cargo.toml / Cargo.lock.

Diving into the exact details of what the author is saying is a bit outside my headspace at the moment. I think the author of the article may not actually understand the scenario where Go's package system differs. (I'm not sure I do, either.)

Suppose you have your project, projectA, and its direct dependency, libB. Then libB has a dependency on libC.

If projectA has a lockfile, you get exactly the same versions of libA and libB. This is true for Go, NPM, and Cargo. However, suppose projectA is a new project. You just created it. In Go, the version of libB that makes it into the lockfile will be the minimum version that libA requires, which means that any new, poisoned version of libB will not transitively affect anything that depends on libA, such as projectA. With NPM, you get the latest version of libB which is compatible with libA--this version may be poisoned.


> any new, poisoned version of libB

Conversely, you will get any old security-buggy version of libB instead.

Most package managers when adding a new dependency assume newer versions are "better" than older versions. Go's minimum version system assumes older is better than newer.

I don't think there's any clear argument you can make on first principles for which of those is actually the case. You'd probably have to do an empirical analysis of how often mailicious packages get published versus how often security bug fix versions get published. If the former is more common than the latter, then min version is likely a net positive for security. If the latter is more common than the former, then max version is probably better. You'd probably also have to evaluate the relative harm of malicious versions versus unintended security bugs.


> I don't think there's any clear argument you can make on first principles for which of those is actually the case.

I don't understand why someone would try to argue from first principles here, it just seems like such a bizarre approach.

Anyway, it's not just a security issue. Malicious packages and security fixes are only part of the picture. Other issues:

- Despite a team's promise to use semantic versioning, point releases & "bugfix" releases will break downstream users

- Other systems for determining the versions to use are much more unpredictable and hard to understand than estimated (look at Dart and Cargo)

https://github.com/dart-lang/pub/blob/master/doc/solver.md

https://github.com/rust-lang/cargo/blob/1ef1e0a12723ce9548d7...


> Other systems for determining the versions to use are much more unpredictable and hard to understand than estimated (look at Dart and Cargo)

I'm one of the co-authors of Dart's package manager. :)

Yes, it is complex. Code reuse is hard and there's no silver bullet.


Nice! I hope I wasn't coming across as critical of Dart's package manager, or Cargo for that matter.


It's OK. There are always valid criticisms of all possible package managers. It's just a hard area with gnarly trade-offs.


Every change that fixes a security issue implies the existence of a change that introduced the security issue in the first place. Why is bumping a version more likely to remove security issues instead of introduce them?

The reason why older is better than newer has more to do with the fact that the author has actually tested their software with that specific version, and so there's more of a chance that it actually works as they intended.


Security issues aren't introduced intentionally, oftentimes they are found much later on in code that was assumed to be secure. Like the SSL heartbleed vulnerability. Once a vulnerability like that is discovered, you _want_ every developer to update their deps to the most secure version


My statement had nothing to do with intent. Conversely, once a vulnerability is introduced (intentionally or not), you don't want every developer to update their deps to the newly insecure version.


Exactly, so it's a trade-off, do you want to encourage updates at the risk of malicious updates (like with node-ipc). Or do you want to add friction to updates and thus risk security vulnerabilities persisting for longer. Node chooses one approach, Go chooses the other.


Again, it's not just malicious updates. Normal updates can also introduce security vulnerabilities. For example, I have a dependency at v1.0 and v1.0.1 introduces a security bug unintentionally. It is eventually fixed in v1.1. If I wait to update until v1.1, then I am not vulnerable to that bug whereas an automatic update to v1.0.1 would be vulnerable. My point is that in expectation, updating your dependency could be just as likely to remove a security vulnerability as it is to add one.


I'd back that down to "most security issues aren't introduced intentionally".


Go just expects you to manually trigger the updates. Thats all. It still is in favor of updating to take security fixes, so i think your argument is wrong.


Let's say my_app uses package foo which uses package bar.

It turns out there is a security bug in bar. The bar maintainers release a patch version that fixes it.

In most package managers, users of my_app can and will get that fix with no work on the part of the author of foo. I'm not very familiar with Go's approach but I thought that unless foo's author puts out a version of foo that bumps its minimum version dependency on bar, my_app won't get the fix. Version solving will continue to select the old buggy version of bar because that's the minimum version the current version of foo permits.


> I thought that unless foo's author puts out a version of foo that bumps its minimum version dependency on bar, my_app won't get the fix. Version solving will continue to select the old buggy version of bar because that's the minimum version the current version of foo permits.

That is incorrect. The application's go.mod defines all dependencies, even indirect ones. Raise the version there, and you raise it for all dependencies. You cannot have one more than one minor version of a dependency in the dependency graph.


That still implies that I need to know to update the version constraint of what may be a very deep transitive dependency, doesn't it?


The version constraint is always listed in your top level go.mod file, so you know the dependency exists, no digging into the dependency tree required at all, and it’s not hidden in some lock file no one ever looks at. Plus, there are plenty of tools that help you with this problem, including the language server helping you directly in your editor and Dependabot on GitHub.

I’m not aware of any languages that send you an email when your dependencies are out of date, so yes, you need to check them. Dependabot can do this for you and open a PR automatically, which will result in an email, so this is one way for people to stay on top of this stuff even for projects they deploy but don’t work on every single week.

If you’re suggesting that indirect dependencies should automatically update themselves, then you are quite literally saying those code authors should have a shell into your production environments that you have no control over, compromising all your systems with a single package update that no one but the malicious author got to review. It is possible with tools like Dependabot to be notified proactively when updates are required so you can review and apply those, but it is not possible to go back in time and un-apply a malicious update that went straight to prod.

Repeatedly assuming that the Go core team never thought through the design of Go Modules and how it relates to security updates is such a strange choice. Go is a very widely used language with tons of great tooling.


I'm sorry but I'm not super familiar with the workflow for working with dependencies in Go, I've only read about it. You say:

> Raise the version there.

Am I to understand that it's common to hand-edit the version constraint on a transitive dependency in your go.mod file?

But that transitive dependency was first added there by the Go tool itself, right?

How does a user easily keep track of what bits of data in the go.mod file are hand-maintained and need to be preserved and which things were filled in implicitly by the tool traversing dependency graphs?

> Repeatedly assuming that the Go core team never thought through the design of Go Modules

I'm certainly not assuming that. But I'm also not assuming they found a perfect solution to package management that all other package management tools failed to find. What's more likely is that they chose a different set of trade-offs, which is what this thread is exploring.


> Am I to understand that it's common to hand-edit the version constraint on a transitive dependency in your go.mod file?

No, run `go get <package with vuln>@<version that fixes vuln>` and Go will do it for you.


>No, run `go get <package with vuln>@<version that fixes vuln>` and Go will do it for you.

The point GP was making is that it's not a given you'll know that there's a security vuln in a sub-sub-sub-dependency of your app. Is it reasonable to expect developers to manually keep tabs on what could be dozens of libraries that may or may not intersect with the dependencies of any other apps you have on the go?

Maybe for Google scale where you can "just throw more engineers at the problem".


> Is it reasonable to expect developers to manually keep tabs on what could be dozens of libraries that may or may not intersect with the dependencies of any other apps you have on the go?

Well, in the NPM model you need at least one transitive dependency to notice it and upgrade, and you need to notice your transitive dependency upgraded. But also, it might upgrade despite nobody asking for it just because you set up a new dependency.

In the Go model... you need at least one transitive dependency to notice it and upgrade, and you need to notice your transitive dependency upgraded. But at least it won't ever upgrade unless someone asked for it.


Well, the easy thing is just let something like Dependabot update your stuff. If you are just wanting "update all my stuff to the latest version", just run `go get -u ./...`?


> Am I to understand that it's common to hand-edit the version constraint on a transitive dependency in your go.mod file?

"Common" is probably not accurate, but what's wrong with hand editing it? If you mess up, your project won't compile until you fix it. You can update the versions a dozen different ways: editing by hand, `go get -u <dependency>` to update a specific dependency, `go get -u ./...` to update all dependencies, using your editor to view the go.mod file and select dependencies to update with the language server integration or by telling the language server to update all dependencies, by using Dependabot, or however else you like to do it. The options are all there for whatever way you're most comfortable with.

> But that transitive dependency was first added there by the Go tool itself, right?

So what? It's still your dependency now, and you are equally as responsible for watching after it as you are for any direct dependency. Any package management system that hides transitive dependencies is encouraging the user to ignore a significant fraction of the code that makes up their application. Every dependency is important.

> How does a user easily keep track of what bits of data in the go.mod file are hand-maintained and need to be preserved and which things were filled in implicitly by the tool traversing dependency graphs?

You already know[0] the answer to the most of that question, so I don't know why you're asking that part again. As a practical example, here is Caddy's go.mod file.[1] You can see the two distinct "require" blocks and the machine-generated comments that inform the reader that the second block is full of indirect dependencies. No one looking at this should be confused about which is which.

But the other part of that question doesn't really make sense. If you don't "preserve" the dependency, your code won't compile because there will be an unsatisfied dependency, regardless of whether you wrote the dependency there by hand or not, and regardless of whether it is a direct or indirect dependency. If you try to compile a program that depends on something not listed in the `go.mod` file, it won't compile. You can issue `go mod tidy` at any time to have Go edit your go.mod file for you to satisfy all constraints, so if you delete a line for whatever reason, `go mod tidy` can add it back, and Go doesn't update dependencies unless you ask it to, so `go mod tidy` won't muck around with other stuff in the process. There are very few ways the user can shoot themselves in the foot here.

Regardless, it is probably uncommon for people to manually edit anything in the go.mod file when there are so many tools that will handle it for you. The typical pattern for adding a dependency that I've observed is to add the import for the dependency from where you're trying to use it, and then tell your editor to update the go.mod file to include that dependency for you. The only time someone is likely to add a dependency to the go.mod file by hand editing it is when they need to do a "replace" operation to substitute one dependency in place of another (which applies throughout the dependency tree), usually only done when you need to patch a bug in third party code before the upstream is ready to merge that fix.

In my experience, most people either use Dependabot to keep up to date with their dependencies, or they update the dependencies using VS Code to view the go.mod file and click the "buttons"(/links/whatever) that the language server visually adds to the file to let you do the common tasks with a single click. They're both extremely simple to use and help you to update your direct and indirect dependencies.

> But I'm also not assuming they found a perfect solution to package management that all other package management tools failed to find. What's more likely is that they chose a different set of trade-offs, which is what this thread is exploring.

They were extremely late to the party, so they were able to learn from everyone else's mistakes. I really don't think it should be surprising that a latecomer is able to find solutions that other package management tools didn't, because the latecomer has the benefit of hindsight. They went from having some of the worst package management in the industry (effectively none; basically only beating out C and C++... and maybe Python, package management for which has been a nightmare for forever) to having arguably the best. Rust's Cargo comes extremely close, and I've used them both (as well as others) for the past 5+ years in several professional contexts. (Yes, that includes time before Go Modules existed, in the days when there was no real package management built in.) It seems like humans often want to assume that "things probably suck just as much everywhere else, just in different ways, and those people must simply be hiding it", but that's not always the case.

Some "trade-offs":

- For awhile, the `go` command would constantly be modifying your `go.mod` file for you whenever it didn't like what was in there, and that was definitely something I would have chalked up as a "trade-off", but they fixed it. Go will not touch your `go.mod` file unless you explicitly tell it to, which is a huge improvement in the consistency of the user experience.

- Go Modules requires version numbers to start with a "v", which annoys some people, because those people had been using git tags for years to track versions without a "v", so you could argue that's a trade-off too.

- There has been some debate about the way that major versions are implemented, since it requires you to change the name of the package for each major version increment. (By appending “/v2”, “/v3”, etc to the package name. The justification for this was to allow you to import multiple major versions of the same package into your dependency graph — and even the same file — without conflict.)

- The fact that the packages are still named after URLs is a source of consternation for some people, but it's only annoying in practice when you need to move the package from one organization to another or from one domain name to another. It's simply not an issue the rest of the time. Some people are also understandably confused into thinking that third party Go modules can vanish at any time because they're coming from URLs, but there is a transparent, immutable package proxy enabled by default that keeps copies of all[#] versions of all public dependencies that are fetched, so even if the original repo is deleted, the dependency will generally still continue to work indefinitely, and the lock sums will be retained indefinitely to prevent any malicious updates to existing dependency versions, which means that tampering is prevented both locally by your go.sum file and remotely by the proxy as an extra layer of protection for new projects that don't have a go.sum file yet. It is possible to disable this proxy (in whole or in part) or self-host your own if desired, but... I haven't encountered any use case that would dictate either. ([#]: There are a handful of rare exceptions involving packages without proper licenses which will only be cached for short periods of time plus the usual DMCA takedown notices that affect all "immutable" package registries, from what I understand.)

Beyond that... I don't know of any trade-offs. Seriously. I have taken the time to think through this and list what I could come up with above. A "trade-off" implies that some decision they made has known pros and cons. What are the cons? Maybe they could provide some "nicer" commands like `go mod update` to update all your dependencies instead of the somewhat obtuse `go get -u ./...` command? You have complained in several places about how Go combines indirect dependencies into the `go.mod` file, but... how is that not objectively better? Dependencies are dependencies. They all matter, and hiding some of them doesn't actually help anything except maybe aesthetics? I would love to know how making some of them exist exclusively in the lock file helps at all. Before Go Modules, I was always fine with that because I had never experienced anything better, but now I have.

There are plenty of things I would happily criticize about Go these days, but package management isn't one of them... it is simply stellar these days. It definitely did go through some growing pains, as any existing language adopting a new package manager would, and the drama that resulted from such a decision left a bitter taste in the mouth of some people, but I don't believe that bitterness was technical as much as it was a result of poor transparency from the core team with the community.

[0]: https://news.ycombinator.com/item?id=30870862

[1]: https://github.com/caddyserver/caddy/blob/master/go.mod


> You already know[0] the answer to the most of that question, so I don't know why you're asking that part again.

My point is that once you start editing (either by hand or through a tool) the version numbers in the second section, then that's no longer cached state derived entirely from the go.mod files of your dependencies which can be regenerated from scratch. It contains some human-authored decisions and regenerating that section without care will drop information on the floor.

Imagine you:

1. Add a dependency on foo, which depends on bar 1.1.

2. Decide to update the version of bar to 1.2.

3. Commit.

4. Weeks later, remove the dependency on foo and tweak some other dependencies. Tidy your go.mod file.

5. Change your mind and re-add the dependency on foo.

At this point, if you look at the diff of your go.mod file, you see that the indirect dependency on bar has changed from 1.2 to 1.1. Is that because:

A. You made a mistake and accidentally lost a deliberate upgrade to that version and you should revert that line.

B. It's a correct downgrade because your other dependency changes which have a shared dependency on bar no longer need 1.2 and it is correctly giving you the minimum version 1.1.

Maybe the answer here is that even when you ask the tool to remove unneeded transitive dependencies, it won't roll back to an earlier version of bar? So it will keep it at 1.2?

With other package management tools, this is obvious: Any hand-authored intent to pin versions—including for transitive dependencies—lives in one file, and all the state derived from that is in another.

> In my experience, most people either use Dependabot to keep up to date with their dependencies, or they update the dependencies using VS Code to view the go.mod file and click the "buttons"(/links/whatever) that the language server visually adds to the file to let you do the common tasks with a single click. They're both extremely simple to use and help you to update your direct and indirect dependencies.

This sounds like you more or less get the same results as you would in other package managers, but with extra steps.

I don't know. I guess I just don't understand the system well enough.


> It contains some human-authored decisions and regenerating that section without care will drop information on the floor.

> Maybe the answer here is that even when you ask the tool to remove unneeded transitive dependencies, it won't roll back to an earlier version of bar? So it will keep it at 1.2?

I'm not aware of any package management system that will remember dependencies you no longer depend on, except by human error when you forget to remove that dependency but keep punishing yourself and others by making them build and install that unused dependency. No matter where the information lived before it was deleted, it's still up to the human to do the right thing in a convoluted scenario like you're describing.

> With other package management tools, this is obvious: Any hand-authored intent to pin versions—including for transitive dependencies—lives in one file, and all the state derived from that is in another.

That doesn't solve anything if you don't look go back to the commit that had that information. If you do go back to that commit, you have all the information you need right there anyways. You can add your own comments to the `go.mod` file, so if something changed for an important reason, you can keep track of that (and the why) just as easily as you can in any other format. Actually, easier than some... does package.json even support comments yet? But it only matters if you go back and look at the previously-deleted dependency information instead of just blindly re-adding it as I imagine most people would do, which is a huge assumption.

>> It seems like humans often want to assume that "things probably suck just as much everywhere else, just in different ways, and those people must simply be hiding it", but that's not always the case.

> This sounds like you more or less get the same results as you would in other package managers, but with extra steps.

> I don't know. I guess I just don't understand the system well enough.

I've done my best to explain it to you, literally multiple hours of my day writing these comments. Maybe I suck at explaining, or maybe you're not interested in what I have to say, or maybe I'm somehow completely wrong on everything (but no one has bothered to explain how). "More or less the same" is not the same. The nuances make a huge difference, and Go Modules has done things incredibly well on the whole. Package managers aren't a zero sum game where you shuffle a deck of pros and cons, and you end up with the same number of pros and cons at the end.


> I've done my best to explain it to you, literally multiple hours of my day writing these comments.

Not the op, but wanted to express appreciation here. I read all your comments and feel like I gained a lot of clarity and insight from them. Would love to read your blog if you had one.


I appreciate the time you put into these comments and definitely learned a lot from them.

My original point was just that the article reads like an incorrect indictment of all other package managers that use lockfiles. Whether Go does things better is for most part orthogonal to what I was initially trying to get at.


As I understand it, that's true in the simple case. If you have `my_app -> foo -> bar` then there's only one path to bar, and you only get a new bar when you upgrade foo.

It's more complicated in general, with diamond dependencies. There needs to be a chain of module updates between you and foo, with the minimum case being a chain of length one where you specify the version of foo directly.

So, people do need to pay attention to security patch announcements. But popular modules, at least, are likely to be get updated relatively quickly, because only one side of a diamond dependency needs to notice and do a release.


> As I understand it, that's true in the simple case. If you have `my_app -> foo -> bar` then there's only one path to bar, and you only get a new bar when you upgrade foo.

This is not correct. You can update bar independent of foo directly from the top-level go.mod file in your project.


Yes, you can do that by adding a direct dependency on foo. I started by talking about when there isn't a direct dependency on foo.

I explicitly talked about having a direct dependency at the end of the second paragraph.


I'm not talking about direct dependencies. Direct and indirect are all listed in the go.mod file. If they aren't listed there, then they aren't in your final binary. If you delete indirect dependencies from the top-level go.mod, your project will fail to compile.


You keep harping on old buggy version where Go has been very clear that it is operator's explicit responsibility to have correct/updated/fixed versions of dependency running.

It specially does not look good in your case considering you work for Google on a different programing language. If you have a clear point to make then compare it with your approach. Instead of making neutral sounding arguments when they are not.


Don't drag in ad-hominem attacks. If you want to defend Go's approach, explain why having it be the "operator's explicit responsibility" is a good policy, likely to make apps (in general) more secure. The obvious implication of the example given is that, on average, it will be a mess.


The code itself isn’t the only risk factor; it’s weighted by others, like discoverability, which are asymmetric in time. If the white hats fix an issue in version n+1, they’re going to make sure people know. If black hats (or normal devs making a mistake) introduce an issue, no one will tell you about it.

I.e. even if both strategies win just as often, min-version pulls ahead by taking less of a hit from losses.


The author is saying that Go provides the same guarantees with just a package list in the go.mod file that other package managers need both a package list and lock file to solve.

go.sum is essentially a distributed / community maintained transparency log of published versions of packages.


Maybe I'm just not familiar with it enough but I don't see how merging a package manifest and lockfile into a single file is a net win.

This means it's no longer clear which dependencies are immediate and which are transitive. It's not clear which versions are user-authored constraints versus system-authored version selections. For dependencies that are transitive, it's not clear why the dependency is in there and which versions of which other dependencies require it.

Other packages separate these into two files because they are very different sets of information. Maybe Go's minimum version selection makes that not the case, but it still seems user-unfriendly to me to lump immediate and transitive dependencies together.


FWIW, there are two machine-formatted sections of go.mod -- the first for direct dependencies, the second section for indirect dependencies.

(That's as of Go 1.17. Previously, that information was communicated via machine-generated comments in a single section).


That seems reasonable.

I think I personally lean towards keeping them in separate files entirely because I like a clearer separation between human-authored content and machine-derived state.


but if you ever bump up the version of an indirect dependency (maybe to pick up a bugfix earlier), is this now a direct or indirect dependency?


In other systems, you do that by creating a direct dependency. Even though your code doesn't directly import the module, you author an explicit dependency because you care about the version of the module that your app ends up using.

This way, there's a clear separation between human-authored intentional dependencies and the automatically-derived solved versions and transitive dependencies based on that.

If you see a diff in your package manifest, you know a human did that. If you see a diff in the lockfile, you know that was derived from your manifest and the manifests you depend on. The only human input is when they choose to regenerate the lockfile and which packages they request to be unlocked and upgraded. That's still important data because regenerating the lockfile at different points in time will produce different results, but it's a different kind of data and I think it's helpful to keep it separated.


Direct means that your code actually imports it directly instead of transitively. It has nothing to do with the version of that dependency being selected in the go.mod file.


Npm lockfile will refer to same package because you can't republish npm with the same tag (there is also content hash in lockfile).

Referring to version tag in git from go's mod can't guarantee that because you can overwrite tag in git.

Am I wrong?


Yes. The go.sum file that sits alongside go.mod keeps track of the hashes so that no modification like that can be made, and dependency fetches actually transparently go through a module proxy/mirror that keeps those same hashes as well, and it will prevent you from getting an altered version of a known module even if you’re starting a new project and don’t have a sum file yet. Versions can’t be republished.


Thanks for clarification, indeed I can see go.sum being checked in on few go package repos I've checked, nice.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: