Add go-version-file-behavior input

By default an exact version read from go-version-file is used as
written, so a "go 1.22.0" directive pins CI to the oldest patch
release of the minor even when newer patches with security fixes
exist. Dependencies can force such an exact version into go.mod.

Setting go-version-file-behavior to latest-patch widens an exact
major.minor.patch version into a ~X.Y.Z range, resolving the newest
available patch release of the same minor while keeping the file's
version as a floor. Bare minors and prereleases pass through
unchanged. The default behavior (exact) is unchanged.
This commit is contained in:
John Maguire 2026-08-18 11:35:13 -04:00
commit 71b6f8926f
7 changed files with 168 additions and 0 deletions

View file

@ -44,6 +44,11 @@ See [action.yml](action.yml).
# Note: if both go-version and go-version-file are provided, go-version takes precedence.
go-version-file: 'go.mod'
# How to interpret an exact version read from go-version-file.
# Set to latest-patch to use the newest patch release of the same minor version.
# Default: exact
go-version-file-behavior: 'exact'
# Set this option if you want the action to check for the latest available version
# Default: false
check-latest: false

View file

@ -1089,6 +1089,98 @@ use .
);
});
describe('go-version-file-behavior', () => {
const buildGoMod = (goVersion: string) => `module example.com/mymodule
go ${goVersion}
`;
it('resolves the latest patch of the minor with latest-patch', async () => {
os.platform = 'linux';
os.arch = 'x64';
inputs['go-version-file'] = 'go.mod';
inputs['go-version-file-behavior'] = 'latest-patch';
inputs['token'] = 'faketoken';
existsSpy.mockImplementation(() => true);
readFileSpy.mockImplementation(() => Buffer.from(buildGoMod('1.12.16')));
const expectedUrl =
'https://github.com/actions/go-versions/releases/download/1.12.17-20200616.21/go-1.12.17-linux-x64.tar.gz';
// ... but not in the local cache
findSpy.mockImplementation(() => '');
dlSpy.mockImplementation(async () => '/some/temp/path');
const toolPath = path.normalize('/cache/go/1.12.17/x64');
extractTarSpy.mockImplementation(async () => '/some/other/temp/path');
cacheSpy.mockImplementation(async () => toolPath);
await main.run();
expect(logSpy).toHaveBeenCalledWith(
'Using latest patch release satisfying ~1.12.16 (version file specifies 1.12.16)'
);
expect(logSpy).toHaveBeenCalledWith('Setup go version spec ~1.12.16');
expect(logSpy).toHaveBeenCalledWith(
`Acquiring 1.12.17 from ${expectedUrl}`
);
});
it('leaves a bare minor version unchanged with latest-patch', async () => {
inputs['go-version-file'] = 'go.mod';
inputs['go-version-file-behavior'] = 'latest-patch';
existsSpy.mockImplementation(() => true);
readFileSpy.mockImplementation(() => Buffer.from(buildGoMod('1.14')));
await main.run();
expect(logSpy).toHaveBeenCalledWith('Setup go version spec 1.14');
});
it('uses the exact version by default', async () => {
inputs['go-version-file'] = 'go.mod';
existsSpy.mockImplementation(() => true);
readFileSpy.mockImplementation(() => Buffer.from(buildGoMod('1.12.16')));
await main.run();
expect(logSpy).toHaveBeenCalledWith('Setup go version spec 1.12.16');
});
it('does not apply to the go-version input', async () => {
inputs['go-version'] = '1.12.16';
inputs['go-version-file-behavior'] = 'latest-patch';
await main.run();
expect(logSpy).toHaveBeenCalledWith('Setup go version spec 1.12.16');
});
it('fails on an unsupported value', async () => {
inputs['go-version-file'] = 'go.mod';
inputs['go-version-file-behavior'] = 'newest';
existsSpy.mockImplementation(() => true);
readFileSpy.mockImplementation(() => Buffer.from(buildGoMod('1.12.16')));
await main.run();
expect(cnSpy).toHaveBeenCalledWith(
`::error::Invalid go-version-file-behavior: 'newest'. Supported values: 'exact', 'latest-patch'${osm.EOL}`
);
});
it.each([
['1.22.0', '~1.22.0'],
['1.22', '1.22'],
['1.21rc2', '1.21rc2'],
['1.22.x', '1.22.x'],
['>=1.22.0', '>=1.22.0']
])('latestPatchSpec(%s) == %s', (version, expected) => {
expect(im.latestPatchSpec(version)).toBe(expected);
});
});
describe('go-version-file-toolchain', () => {
const goVersions = ['1.22.0', '1.21rc2', '1.18'];
const placeholderVersion = '1.19';

View file

@ -6,6 +6,9 @@ inputs:
description: 'The Go version to download (if necessary) and use. Supports semver spec and ranges. Be sure to enclose this option in single quotation marks.'
go-version-file:
description: 'Path to the go.mod, go.work, .go-version, or .tool-versions file.'
go-version-file-behavior:
description: 'How to interpret an exact version read from go-version-file. Use "latest-patch" to resolve the newest available patch release of the same minor version (e.g. "1.22.0" in go.mod resolves to the newest 1.22.x). Defaults to "exact", which uses the version as written.'
default: exact
check-latest:
description: 'Set this option to true if you want the action to always check for the latest available version that satisfies the version spec'
default: false

22
dist/setup/index.js vendored
View file

@ -43581,6 +43581,17 @@ function parseGoVersionFile(versionFilePath) {
}
return contents.trim();
}
// Widen an exact version from a version file into a semver range matching
// the newest patch release of the same minor (go-version-file-behavior:
// latest-patch). Only exact major.minor.patch versions are widened: bare
// minors like '1.22' already resolve to the newest patch, and prereleases
// like '1.21rc2' have no patch series to float within.
function latestPatchSpec(version) {
if (!/^\d+\.\d+\.\d+$/.test(version)) {
return version;
}
return `~${version}`;
}
async function resolveStableVersionDist(versionSpec, arch) {
const archFilter = getArch(arch);
const platFilter = getPlatform();
@ -100360,6 +100371,17 @@ function resolveVersionInput() {
throw new Error(`The specified go version file at: ${versionFilePath} does not exist`);
}
version = parseGoVersionFile(versionFilePath);
const behavior = getInput('go-version-file-behavior') || 'exact';
if (behavior === 'latest-patch') {
const spec = latestPatchSpec(version);
if (spec !== version) {
core_info(`Using latest patch release satisfying ${spec} (version file specifies ${version})`);
version = spec;
}
}
else if (behavior !== 'exact') {
throw new Error(`Invalid go-version-file-behavior: '${behavior}'. Supported values: 'exact', 'latest-patch'`);
}
}
return version;
}

View file

@ -3,6 +3,7 @@
- [Specifying a go version](advanced-usage.md#specifying-a-go-version)
- [Matrix testing](advanced-usage.md#matrix-testing)
- [Using the go-version-file input](advanced-usage.md#using-the-go-version-file-input)
- [Using the latest patch release](advanced-usage.md#using-the-latest-patch-release)
- [Check latest version](advanced-usage.md#check-latest-version)
- [Caching](advanced-usage.md#caching)
- [Caching in monorepos](advanced-usage.md#caching-in-monorepos)
@ -212,6 +213,24 @@ steps:
- run: go version
```
### Using the latest patch release
By default, an exact version read from the version file is used as written: a `go 1.22.0` directive installs exactly Go 1.22.0, even if newer 1.22.x patch releases with security fixes are available.
Set `go-version-file-behavior` to `latest-patch` to instead resolve the newest available patch release of the same minor version that is at least the version in the file (e.g., `go 1.22.0` resolves to the newest 1.22.x):
```yaml
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: 'go.mod'
go-version-file-behavior: 'latest-patch'
- run: go version
```
Versions without a patch component (e.g., `go 1.22`) already resolve to the latest available patch release, and prerelease versions (e.g., `go1.22rc1` from a `toolchain` directive) are always used as written, so `latest-patch` leaves both unchanged. As with any version range, the resolved patch release depends on what is available in the runner's tool cache and the versions manifest.
## Check latest version
The `check-latest` flag defaults to `false`. Use the default or set `check-latest` to `false` if you prefer stability

View file

@ -672,6 +672,18 @@ export function parseGoVersionFile(versionFilePath: string): string {
return contents.trim();
}
// Widen an exact version from a version file into a semver range matching
// the newest patch release of the same minor (go-version-file-behavior:
// latest-patch). Only exact major.minor.patch versions are widened: bare
// minors like '1.22' already resolve to the newest patch, and prereleases
// like '1.21rc2' have no patch series to float within.
export function latestPatchSpec(version: string): string {
if (!/^\d+\.\d+\.\d+$/.test(version)) {
return version;
}
return `~${version}`;
}
async function resolveStableVersionDist(
versionSpec: string,
arch: Architecture

View file

@ -173,6 +173,21 @@ function resolveVersionInput(): string {
);
}
version = installer.parseGoVersionFile(versionFilePath);
const behavior = core.getInput('go-version-file-behavior') || 'exact';
if (behavior === 'latest-patch') {
const spec = installer.latestPatchSpec(version);
if (spec !== version) {
core.info(
`Using latest patch release satisfying ${spec} (version file specifies ${version})`
);
version = spec;
}
} else if (behavior !== 'exact') {
throw new Error(
`Invalid go-version-file-behavior: '${behavior}'. Supported values: 'exact', 'latest-patch'`
);
}
}
return version;