Sunday, September 17, 2023

Go versioning and modules

Go has evolved into the de facto language for building control plane and management plane services, and along the way the Go tooling has picked up quite a few semantics around versioning and dependency management. This is my attempt at a round up of it all, leaving the details to the fantastic articles linked at the end of this post.

Package versions

If you maintain a Go package on a git repo that other Go programs import, then it is strongly recommended to have a notion of versioning and release defined for your package and repo.

  • The standard practice is to use semver (semantic versioning) of the form <MAJOR_VER>.<MINOR_VER>.<PATCH_VER>.
  • An optional pre-release version tag can be suffixed using a hyphen, such as 1.2.0-alpha or 1.2.9-beta.2. A pre-release version is considered an unstable version.
  • Major versions indicate API generations.
    • Major version 0 indicates evolution and potentially unstable interfaces and implementations.
    • Major version 1 indicates that the API has stabilized, although additional interfaces could be added.
    • Further major version updates are mandated if and only if there are breaking changes in the API.
  • Minor versions indicate API and implementation progression that maintain backward compatibility.
  • Patch versions indicate bug fixes and improvements without API changes.
A point to note is that major version 0 is treated a bit differently. You could have breaking changes between 0.1 and 0.2, for example, and backward compatible API changes between 0.1.1 and 0.1.2. This is unlike say 1.1 and 1.2, which cannot have breaking changes between them, and 1.1.1 and 1.1.2 which cannot have API changes (even backward-compatible ones) between them.

For reasons that would become clear shortly, it is important to tag specific commits with these versions in the repo, using git tags. This allows the git repo containing your Go code to participate in versioned dependency management that go tools support. The convention for tags is the letter v followed by the version string.

Modules and packages

Modules are now the standard way for doing dependency management in Go. A module is just a directory structure with Go source code under it, and a go.mod file at the root of the directory structure acting as a manifest. The go.mod file contains the module path name that would be used to address the module, and the Go version used to generate the module. In addition, it contains a list of external packages that the code inside the module depends on, and their versions. Three things are important:
  • Code inside and outside the module should import packages inside the module using the module path and the path of the package within the module relative to the module root.
  • Go tooling automatically determines a module version to use for each package dependency.
    • If available, the latest tagged stable version is used. Here stable refers
    • If not, then the latest pre-release (i.e. unstable) version is used.
    • If not, then the latest untagged version is used. A pseudo-version string is generated for this of the form v0.0.0-<yyyymmddhhmmss>-<commit_hash>. This is the reason, it is better to have tagged versions indicating stability and backward compatibility.
  • Once a dependency of a module on a specific version of another package is established, it would not be automatically changed.
There are several important commands related to go modules that we need to know, and use as needed.

To create a module in a directory, we must run the following at the module root:
go mod init <module_path>
This generates the go.mod file.

To update module dependencies, including cleaning up obsolete dependencies, we must run the following at the module root:
go mod tidy
This updates the dependencies in the go.mod file.

By default, just building code within a package also updates the dependencies inside go.mod. However it does not clean up obsolete dependencies. We can also add a new dependency explicitly, without running a build, using:
go get <package>

To update to latest the minor version or patch version of a dependency, we can run:
go get -u <package>
Or, to specifically upgrade only the patch version without upgrading the minor version:
go get -u=patch <package>
One can also upgrade to a specific minor / patch version:
go get -u=patch <package>@<semver>
You'd need this when you want to test your code against a pre-release version of a package (that has used some discipline to also define and tag stable versions).

Major version upgrades

A major version upgrade for a Go package should be rare, and it would typically be rarer after a couple of major version upgrades. Why? Because a major version upgrade typically represents a new API and a new way of doing things. It necessarily breaks the older API. It means that the older API must continue to be supported for a decent time for clients to move to the newer version (unless for some specific or perverse reason you can leave your clients in the lurch). In that way, Go tooling treats v2 and beyond differently from v0 and v1.

Essentially, your code could link against two different major version of a given package. This is not possible to do with two different minor or patch versions under the same major version of a given package. This allows parts of your code to start using a newer major version of a dependency without all the code moving at the same time. This may or may not always be technically feasible but when it is, this is convenient.

  • The package maintainer would typically create their new major version package in a separate subdirectory of the older package, and name it v2 or v3, or so on, as a convention.
    • The code for the new major version could be a copy of the old code that is then changed, or a complete rewrite, or some mix of the two. Internally, the code for the new major version may continue to call older code that is still useful. These details are hidden from the client.
  • The client would import the package with the new version by including the /v2 or /v3 etc. version directory suffix in the package path.
    • Usually v0 and v1 do not require a suffix. But v2 onwards, the suffix is recommended.
    • If two different major versions are imported, a different explicit alias is used for the higher version. For example, mypkg and mypkgV2.
    • Going ahead, at some point if all dependencies on v0/v1 are removed, the mypkgV2 alias can be removed as well and Go compiler would import the mypkg/v2 package with the alias mypkg automatically.

Private repos

<Stuff to cover about the GOPRIVATE environment variable, personal access tokens, etc.>

References

The sequence of articles starting here is actually all you need and more.

  1. Using Go Modules
  2. Go Workspace

No comments: