# setup-go: pinning a Go toolchain inside a GitHub Actions job

> actions/setup-go resolves a Go version from three sources in order, caches modules against go.mod, and changed its default cache key and Node runtime across recent major versions. Here is what that means for a workflow you maintain.

**actions/setup-go** — Set up your GitHub Actions workflow with a specific version of Go

- Repository: https://github.com/actions/setup-go
- Stars: 1,763 · Forks: 674
- Language: TypeScript
- License: MIT
- Published: 2026-10-16 · Updated: 2026-10-16 · Language: en
- Canonical page: https://hysenlabs.com/projects/actions-setup-go

## Three places the Go version is looked up, in order

The interesting behaviour of this action is not the download, it is the fallback chain. A job asks for a version, and the action looks in the local tool cache on the runner first, matching against semver. If nothing matches, it pulls the version manifest from the `main` branch of the separate go-versions repository. If that lookup misses or fails outright, it goes to the official Go distribution site and downloads from there.

Three consequences follow. First, a job that resolves locally never touches the network for the version list, which makes repeated runs on a warm self-hosted runner fast and quiet. Second, the manifest is pinned to a branch rather than a tag, so a corrupt or truncated manifest from that branch pushes you straight to the third source rather than failing. Third, if you need to bypass the cache-first behaviour, the README points at the `check-latest` input in the advanced usage guide, and the usage block defaults it to `false`.

The action also states plainly that it uses executable binaries built by the Go team and does not compile Go from source. That is the right call for CI speed, but it removes the option of patching the toolchain in a workflow.

## Quoting the version, because YAML eats the trailing zero

The single most common surprise with this action is not a failure. It is a wrong version. An unquoted value such as `1.20` is parsed by YAML as a number, and the number 1.20 collapses to 1.2, which is a real Go release line that your job was never asking for. The build proceeds, the toolchain is old, and the log looks healthy.

The README recommends wrapping the version in single quotation marks, and every example in the usage section already does it:

```yaml
- uses: actions/setup-go@v7
  with:
    # Version or version range of Go to use
    go-version: '1.23'
```

The `go-version` input accepts far more than an exact patch number. Specific versions such as `1.24.11`, `1.24.0-rc.1`, and `1.23.0-beta.1` work, as do the semver range forms `^1.25.1`, `~1.24.1`, `>=1.25.0-rc.1`, `<1.25.0`, and a bounded range such as `>=1.22.0 <1.24.0`. The aliases `stable` and `oldstable` exist for jobs that should follow the newest release without a manual bump, and wildcards like `1.25.x` or `1.x` cover whole minor lines.

Ranges and aliases are convenient and they quietly change what a build produces on a given day. A range that resolves differently next month is a supply chain question as much as a maintenance one.

## The minimum real workflow

The Basic example is three steps: check out the repository, run setup-go with a pinned version, then execute something. Note the checkout tag is `actions/checkout@v7`, so the two actions move in step with each other.

```yaml
steps:
  - uses: actions/checkout@v7
  - uses: actions/setup-go@v7
    with:
      go-version: '1.25' # The Go version to download (if necessary) and use.
  - run: go run hello.go
```

There is also a `go-version-file` input that reads the version from `go.mod`, `go.work`, `.go-version`, or a `.tool-versions` file, which removes the duplicated version literal from the workflow and lets the module definition be the single source of truth. If you supply both, `go-version` wins, and the usage comments state that precedence explicitly. That ordering is worth remembering, because a leftover `go-version` line quietly overrides the file you thought was driving the build.

The README also recommends scoping workflow permissions, since the action needs to read the repository and install dependencies:

```yaml
permissions:
  contents: read # access to check out code and install dependencies
```

A `token` input defaults to the workflow's own `github.token`, which is what makes the cache save and restore work without you writing a secret.

## V6 changed the cache key from go.sum to go.mod

Caching is on by default and there is no configuration to write, which is the appeal. The action uses toolkit/cache underneath, but derives the key itself. The catch arrived in V6: by default the cache key for Go modules is based on `go.mod`. Teams that pin dependencies in `go.sum` and expect their cache to rotate when a dependency version changes no longer get that rotation by default, because the hash they cared about is no longer in the key.

The fix is one input. Point `cache-dependency-path` at the file you actually want hashed:

```yaml
- uses: actions/setup-go@v7
  with:
    go-version: '1.25'
    cache-dependency-path: 'go.sum'
```

The same input is what you need when dependency files live in subdirectories or a workspace produces more than one. It accepts glob patterns, so a monorepo can hash every module file in one go.

Two failure behaviours are worth knowing. If caching cannot be performed for any reason, the action logs a warning and continues rather than failing the job. And `cache: false` turns the whole thing off when you would rather have a cold, reproducible module download than a warm cache you cannot account for.

## V6 moved the runtime to node24, which sets a floor on the runner

The V6 major release upgraded the Node.js runtime from node20 to node24. That is not a cosmetic change: the README ties it to a runner requirement, stating you need version v2.327.1 or later for compatibility and linking the corresponding release notes.

So the upgrade path for this action is really an upgrade path for your runners. A self-hosted runner image that has not been updated past that version cannot host V6 or V7, which means an organisation with a long-lived fleet has to sequence the runner update before the action update. Cloud-hosted runners are not affected in the same way.

The other V6 change is about `go.mod` parsing. The action supports both the `go` and the `toolchain` directive. If `toolchain` is present its version is used, otherwise the action falls back to the `go` directive. That precedence is invisible in a workflow file and only shows up in the log, so a repository with a stale `toolchain go1.21` line above a `go 1.25` line will build on 1.21.

V7 is smaller by comparison: the internals moved to ESM for compatibility with the latest `@actions/*` packages, with no change to inputs, outputs, or behavior. The `package.json` reflects the shift, declaring `"type": "module"` and an engine floor of node 24.0.0.

## Mirrors, custom base URLs, and GHES

Downloads are not hard-wired to one host. There is a `go-download-base-url` input, empty by default, described as a custom base URL for Go downloads with mirrors as the example. In an air-gapped or region-restricted network this is the input that decides whether the job works at all.

There is also an `architecture` input, with `x64` given in the usage block and auto-detection when left unspecified. On an ARM runner, leaving it out is safer than guessing.

The advanced usage guide lists a dedicated section on using setup-go on GHES, alongside sections on the `go-version` input, the `go-version-file` input, checking the latest version, caching, outputs, and the custom download URL. None of those details are duplicated in the README, so the guide is the place to look for GHES specifics and for what the action actually exposes as outputs.

## What it does not do, and the alternative approach

The limits are structural rather than rough. This action installs a prebuilt toolchain from official sources and nothing more. It does not build Go from source, it does not provision cgo system headers or libraries, and it does not manage a language server or an editor integration. A module that needs libsqlite3 headers still needs an apt or brew step, and no input on this action will produce one.

The alternative most teams actually choose is containerised builds: run the job inside a `golang:` image and skip this action entirely. The trade is real in both directions. A pinned image tag gives you an immutable environment including the OS packages and the toolchain together, at the cost of pulling a larger image on a cold cache and of maintaining image tags yourself. setup-go keeps the runner image thin and keeps the toolchain version legible as one line of YAML, but gives you no handle on anything outside the Go distribution.

If your team already standardises on Docker images for every language, adding setup-go alongside is redundant. If you want a small runner and a readable version pin, it stays.

## Upgrading the tag is cheap, upgrading the runner is not

The repository is not archived and the last push was on 2026-10-05. Tags moved from v6.4.0 on 2026-03-30 to v6.5.0 on 2026-06-24 and v7.0.0 on 2026-07-16, so the action is released on a steady cadence and major bumps arrive inside that cadence rather than being held back.

Licensing is MIT for both the scripts and the documentation.

The maintenance cost sits almost entirely in the node24 migration. Everything else in V6 and V7 is behaviour preserving or a default you can override with an input. So the sequence worth planning is: update the runner fleet past v2.327.1 first, move the tag from `@v5` or `@v6` to `@v7`, then decide deliberately whether to set `cache-dependency-path` rather than inheriting the go.mod default. Those are three separate decisions, and the action gives you no signal about whether you made the third one.

## Conclusion

setup-go is the sensible default for any workflow that compiles Go, provided you quote the version string and check which major tag your runner can host. It does not fit a self-hosted runner older than v2.327.1 on V6 or newer, and it does not build Go from source, so a module needing cgo against system libraries still needs provisioning steps outside the action. Verify first that your go.mod uses the go directive alone or a toolchain directive you intended, since V6 prefers toolchain when both are present.

## FAQ

### How do I install a specific Go version in a GitHub Actions workflow?

Add actions/setup-go with the `go-version` input and wrap the value in single quotation marks so YAML does not parse it as a number. A minimal job is a checkout step, the setup-go step with `go-version: '1.25'`, and then your own `run` step.

### Why did my Go module cache stop updating after upgrading setup-go?

In V6 the default cache key for Go modules is based on go.mod instead of go.sum, so a change that only touches go.sum no longer invalidates the cache. Set `cache-dependency-path: 'go.sum'` to restore the previous key, and use the same input with glob patterns when dependency files sit in subdirectories or a monorepo has several of them.

### Does setup-go work with both the go and toolchain directives in go.mod?

Yes. Since V6 the action reads both, and if a `toolchain` directive is present its version is used, otherwise the action falls back to the `go` directive. This also means a stale toolchain line in go.mod will win over your go directive.

## Sources

- [actions/setup-go on GitHub](https://github.com/actions/setup-go)
- [Issues](https://github.com/actions/setup-go/issues)
- [License: MIT](https://github.com/actions/setup-go/blob/main/LICENSE)
- [README](https://github.com/actions/setup-go/blob/main/README.md)
- [Releases](https://github.com/actions/setup-go/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/actions-setup-go
