Resolve latest-patch from the versions manifest and tighten input handling

- latest-patch implies check-latest so the newest patch release comes
  from the versions manifest instead of a possibly stale runner tool
  cache, with a warning when the manifest cannot be reached
- fail fast when latest-patch is combined with a custom download base URL
- never widen an exact toolchain directive pin
- validate go-version-file-behavior on every input path
- widen v-prefixed versions and log when a version is used as written
- document dependency cache invalidation on new patch releases
This commit is contained in:
John Maguire 2026-08-18 13:28:12 -04:00
commit f75efb3f19
6 changed files with 246 additions and 51 deletions

View file

@ -1122,11 +1122,49 @@ go ${goVersion}
'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(
'go-version-file-behavior: latest-patch implies check-latest'
);
expect(logSpy).toHaveBeenCalledWith(
'Attempting to resolve the latest version from the manifest...'
);
expect(logSpy).toHaveBeenCalledWith(
`Acquiring 1.12.17 from ${expectedUrl}`
);
});
it('warns and falls back when the manifest cannot be resolved', 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')));
getManifestSpy.mockImplementation(() => {
throw new Error('Unable to download manifest');
});
(httpmGetJsonSpy as jest.Mock<any>).mockRejectedValue(
new Error('Unable to download manifest from raw URL')
);
// ... and not in the local cache, so the dist fallback downloads
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(cnSpy).toHaveBeenCalledWith(
`::warning::go-version-file-behavior: latest-patch could not be honored: unable to resolve ~1.12.16 from the versions manifest. Falling back to the version spec, which may resolve to an older patch release from the runner's tool cache.${osm.EOL}`
);
expect(dlSpy).toHaveBeenCalled();
});
it('leaves a bare minor version unchanged with latest-patch', async () => {
inputs['go-version-file'] = 'go.mod';
inputs['go-version-file-behavior'] = 'latest-patch';
@ -1135,9 +1173,47 @@ go ${goVersion}
await main.run();
expect(logSpy).toHaveBeenCalledWith(
'Using version 1.14 as written (latest-patch only widens exact major.minor.patch versions)'
);
expect(logSpy).toHaveBeenCalledWith('Setup go version spec 1.14');
});
it('does not widen an explicit toolchain directive pin', async () => {
inputs['go-version-file'] = 'go.mod';
inputs['go-version-file-behavior'] = 'latest-patch';
existsSpy.mockImplementation(() => true);
readFileSpy.mockImplementation(() =>
Buffer.from(`module example.com/mymodule
go 1.21
toolchain go1.22.3
`)
);
await main.run();
expect(logSpy).toHaveBeenCalledWith(
'Using toolchain directive version 1.22.3 as written (latest-patch does not widen an explicit toolchain pin)'
);
expect(logSpy).toHaveBeenCalledWith('Setup go version spec 1.22.3');
});
it('fails when combined with a custom download base URL', async () => {
inputs['go-version-file'] = 'go.mod';
inputs['go-version-file-behavior'] = 'latest-patch';
inputs['go-download-base-url'] = 'https://internal.example.com/go';
existsSpy.mockImplementation(() => true);
readFileSpy.mockImplementation(() => Buffer.from(buildGoMod('1.22.0')));
await main.run();
expect(cnSpy).toHaveBeenCalledWith(
`::error::go-version-file-behavior: 'latest-patch' is not supported with a custom download base URL because version ranges cannot be resolved against it. Use the default 'exact' behavior.${osm.EOL}`
);
});
it('uses the exact version by default', async () => {
inputs['go-version-file'] = 'go.mod';
existsSpy.mockImplementation(() => true);
@ -1170,8 +1246,20 @@ go ${goVersion}
);
});
it('fails on an unsupported value even when go-version is used', async () => {
inputs['go-version'] = '1.12.16';
inputs['go-version-file-behavior'] = 'newest';
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'],
['v1.22.0', '~1.22.0'],
['1.22', '1.22'],
['1.21rc2', '1.21rc2'],
['1.22.x', '1.22.x'],

View file

@ -7,7 +7,7 @@ inputs:
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.'
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); this implies check-latest and is not supported with go-download-base-url. 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'

72
dist/setup/index.js vendored
View file

@ -43165,7 +43165,7 @@ const GOLANG_DOWNLOAD_URL = 'https://go.dev/dl/?mode=json&include=all';
// For these URLs we skip the getInfoFromDist() call entirely and construct
// the download URL directly, avoiding a guaranteed-404 HTTP request.
const NO_VERSION_LISTING_BASE_URLS = ['https://aka.ms/golang/release/latest'];
async function getGo(versionSpec, checkLatest, auth, arch = external_os_default().arch(), goDownloadBaseUrl) {
async function getGo(versionSpec, checkLatest, auth, arch = external_os_default().arch(), goDownloadBaseUrl, latestPatchApplied = false) {
let manifest;
const osPlat = external_os_default().platform();
const customBaseUrl = goDownloadBaseUrl?.replace(/\/+$/, '');
@ -43196,6 +43196,11 @@ async function getGo(versionSpec, checkLatest, auth, arch = external_os_default(
versionSpec = resolvedVersion;
core_info(`Resolved as '${versionSpec}'`);
}
else if (latestPatchApplied) {
// latest-patch depends on the manifest to see patches newer than
// the runner's tool cache, so a silent info line is not enough here
warning(`go-version-file-behavior: latest-patch could not be honored: unable to resolve ${versionSpec} from the versions manifest. Falling back to the version spec, which may resolve to an older patch release from the runner's tool cache.`);
}
else {
core_info(`Failed to resolve version ${versionSpec} from manifest`);
}
@ -43568,29 +43573,37 @@ function parseGoVersionFile(versionFilePath) {
// toolchain directive: https://go.dev/ref/mod#go-mod-file-toolchain
const matchToolchain = contents.match(/^toolchain go(1\.\d+(?:\.\d+|rc\d+)?)/m);
if (matchToolchain) {
return matchToolchain[1];
return { version: matchToolchain[1], fromToolchainDirective: true };
}
}
// go directive: https://go.dev/ref/mod#go-mod-file-go
const matchGo = contents.match(/^go (\d+(\.\d+)*)/m);
return matchGo ? matchGo[1] : '';
return {
version: matchGo ? matchGo[1] : '',
fromToolchainDirective: false
};
}
else if (external_path_.basename(versionFilePath) === '.tool-versions') {
const match = contents.match(/^golang\s+([^\n#]+)/m);
return match ? match[1].trim() : '';
return {
version: match ? match[1].trim() : '',
fromToolchainDirective: false
};
}
return contents.trim();
return { version: contents.trim(), fromToolchainDirective: false };
}
// 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
// latest-patch). Only exact major.minor.patch versions are widened,
// optionally with a leading 'v' as found in some .go-version files: 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)) {
const match = version.match(/^v?(\d+\.\d+\.\d+)$/);
if (!match) {
return version;
}
return `~${version}`;
return `~${match[1]}`;
}
async function resolveStableVersionDist(versionSpec, arch) {
const archFilter = getArch(arch);
@ -100261,7 +100274,7 @@ async function run() {
// versionSpec is optional. If supplied, install / use from the tool cache
// If not supplied then problem matchers will still be setup. Useful for self-hosted.
//
const versionSpec = resolveVersionInput();
const { version: versionSpec, latestPatchApplied } = resolveVersionInput();
setGoToolchain();
const cache = getBooleanInput('cache');
core_info(`Setup go version spec ${versionSpec}`);
@ -100272,14 +100285,20 @@ async function run() {
if (versionSpec) {
const token = getInput('token');
const auth = !token ? undefined : `token ${token}`;
const checkLatest = getBooleanInput('check-latest');
let checkLatest = getBooleanInput('check-latest');
if (latestPatchApplied && !checkLatest) {
// the runner's tool cache may only hold a stale patch release; the
// newest one has to come from the versions manifest
core_info('go-version-file-behavior: latest-patch implies check-latest');
checkLatest = true;
}
const goDownloadBaseUrl = getInput('go-download-base-url') ||
process.env['GO_DOWNLOAD_BASE_URL'] ||
undefined;
if (goDownloadBaseUrl) {
core_info(`Using custom Go download base URL: ${goDownloadBaseUrl}`);
}
const installDir = await getGo(versionSpec, checkLatest, auth, arch, goDownloadBaseUrl);
const installDir = await getGo(versionSpec, checkLatest, auth, arch, goDownloadBaseUrl, latestPatchApplied);
const installDirVersion = external_path_default().basename(external_path_default().dirname(installDir));
addPath(external_path_default().join(installDir, 'bin'));
core_info('Added go to the path');
@ -100360,30 +100379,43 @@ function parseGoVersion(versionString) {
function resolveVersionInput() {
let version = getInput('go-version');
const versionFilePath = getInput('go-version-file');
const behavior = getInput('go-version-file-behavior') || 'exact';
if (behavior !== 'exact' && behavior !== 'latest-patch') {
throw new Error(`Invalid go-version-file-behavior: '${behavior}'. Supported values: 'exact', 'latest-patch'`);
}
if (version && versionFilePath) {
warning('Both go-version and go-version-file inputs are specified, only go-version will be used');
}
if (version) {
return version;
return { version, latestPatchApplied: false };
}
if (versionFilePath) {
if (!external_fs_default().existsSync(versionFilePath)) {
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 versionFile = parseGoVersionFile(versionFilePath);
version = versionFile.version;
if (behavior === 'latest-patch' && version) {
if (versionFile.fromToolchainDirective) {
core_info(`Using toolchain directive version ${version} as written (latest-patch does not widen an explicit toolchain pin)`);
}
else {
const spec = latestPatchSpec(version);
if (spec !== version) {
if (spec === version) {
core_info(`Using version ${version} as written (latest-patch only widens exact major.minor.patch versions)`);
}
else {
if (getInput('go-download-base-url') ||
process.env['GO_DOWNLOAD_BASE_URL']) {
throw new Error(`go-version-file-behavior: 'latest-patch' is not supported with a custom download base URL because version ranges cannot be resolved against it. Use the default 'exact' behavior.`);
}
core_info(`Using latest patch release satisfying ${spec} (version file specifies ${version})`);
version = spec;
return { version: spec, latestPatchApplied: true };
}
}
else if (behavior !== 'exact') {
throw new Error(`Invalid go-version-file-behavior: '${behavior}'. Supported values: 'exact', 'latest-patch'`);
}
}
return version;
return { version, latestPatchApplied: false };
}
function setGoToolchain() {
// docs: https://go.dev/doc/toolchain

View file

@ -229,7 +229,20 @@ steps:
- 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.
Because the newest patch release is often not yet present in the runner's tool cache, `latest-patch` implies `check-latest`: the newest matching patch is resolved from the versions manifest rather than from whatever the cache happens to hold.
Two operational effects to be aware of:
- The dependency cache key includes the installed Go version, so with `cache: true` each new Go patch release changes the key: the first run after a patch release rebuilds the module and build caches from scratch.
- If the versions manifest cannot be reached (for example on GitHub Enterprise Server or other runners without github.com access), the action emits a warning and falls back to resolving the version range locally, which may install an older patch release from the runner's tool cache.
Some versions are always used as written and are not affected by `latest-patch`:
- Versions without a patch component (e.g., `go 1.22`), which already resolve to the latest available patch release.
- Prerelease versions (e.g., `go1.22rc1`), which have no patch series to float within.
- An exact version pinned by a go.mod or go.work `toolchain` directive (e.g., `toolchain go1.22.3`): the pin is deliberate and is never widened.
`latest-patch` is not supported together with `go-download-base-url`, which requires an exact version.
## Check latest version

View file

@ -54,7 +54,8 @@ export async function getGo(
checkLatest: boolean,
auth: string | undefined,
arch: Architecture = os.arch() as Architecture,
goDownloadBaseUrl?: string
goDownloadBaseUrl?: string,
latestPatchApplied = false
) {
let manifest: tc.IToolRelease[] | undefined;
const osPlat: string = os.platform();
@ -111,6 +112,12 @@ export async function getGo(
if (resolvedVersion) {
versionSpec = resolvedVersion;
core.info(`Resolved as '${versionSpec}'`);
} else if (latestPatchApplied) {
// latest-patch depends on the manifest to see patches newer than
// the runner's tool cache, so a silent info line is not enough here
core.warning(
`go-version-file-behavior: latest-patch could not be honored: unable to resolve ${versionSpec} from the versions manifest. Falling back to the version spec, which may resolve to an older patch release from the runner's tool cache.`
);
} else {
core.info(`Failed to resolve version ${versionSpec} from manifest`);
}
@ -642,7 +649,16 @@ export function makeSemver(version: string): string {
return fullVersion;
}
export function parseGoVersionFile(versionFilePath: string): string {
export interface GoVersionFileResult {
version: string;
// true when the version came from a go.mod/go.work toolchain directive,
// an explicit pin that must be respected as written
fromToolchainDirective: boolean;
}
export function parseGoVersionFile(
versionFilePath: string
): GoVersionFileResult {
const contents = fs.readFileSync(versionFilePath).toString();
if (
@ -657,31 +673,39 @@ export function parseGoVersionFile(versionFilePath: string): string {
/^toolchain go(1\.\d+(?:\.\d+|rc\d+)?)/m
);
if (matchToolchain) {
return matchToolchain[1];
return {version: matchToolchain[1], fromToolchainDirective: true};
}
}
// go directive: https://go.dev/ref/mod#go-mod-file-go
const matchGo = contents.match(/^go (\d+(\.\d+)*)/m);
return matchGo ? matchGo[1] : '';
return {
version: matchGo ? matchGo[1] : '',
fromToolchainDirective: false
};
} else if (path.basename(versionFilePath) === '.tool-versions') {
const match = contents.match(/^golang\s+([^\n#]+)/m);
return match ? match[1].trim() : '';
return {
version: match ? match[1].trim() : '',
fromToolchainDirective: false
};
}
return contents.trim();
return {version: contents.trim(), fromToolchainDirective: false};
}
// 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
// latest-patch). Only exact major.minor.patch versions are widened,
// optionally with a leading 'v' as found in some .go-version files: 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)) {
const match = version.match(/^v?(\d+\.\d+\.\d+)$/);
if (!match) {
return version;
}
return `~${version}`;
return `~${match[1]}`;
}
async function resolveStableVersionDist(

View file

@ -17,7 +17,7 @@ export async function run() {
// versionSpec is optional. If supplied, install / use from the tool cache
// If not supplied then problem matchers will still be setup. Useful for self-hosted.
//
const versionSpec = resolveVersionInput();
const {version: versionSpec, latestPatchApplied} = resolveVersionInput();
setGoToolchain();
const cache = core.getBooleanInput('cache');
@ -33,7 +33,15 @@ export async function run() {
const token = core.getInput('token');
const auth = !token ? undefined : `token ${token}`;
const checkLatest = core.getBooleanInput('check-latest');
let checkLatest = core.getBooleanInput('check-latest');
if (latestPatchApplied && !checkLatest) {
// the runner's tool cache may only hold a stale patch release; the
// newest one has to come from the versions manifest
core.info(
'go-version-file-behavior: latest-patch implies check-latest'
);
checkLatest = true;
}
const goDownloadBaseUrl =
core.getInput('go-download-base-url') ||
@ -49,7 +57,8 @@ export async function run() {
checkLatest,
auth,
arch,
goDownloadBaseUrl
goDownloadBaseUrl,
latestPatchApplied
);
const installDirVersion = path.basename(path.dirname(installDir));
@ -152,10 +161,25 @@ export function parseGoVersion(versionString: string): string {
return versionString.split(' ')[2].slice('go'.length);
}
function resolveVersionInput(): string {
interface ResolvedVersionInput {
version: string;
// true when latest-patch widened the version into a range; the newest
// matching patch must then be resolved from the versions manifest, not
// just the runner's local tool cache
latestPatchApplied: boolean;
}
function resolveVersionInput(): ResolvedVersionInput {
let version = core.getInput('go-version');
const versionFilePath = core.getInput('go-version-file');
const behavior = core.getInput('go-version-file-behavior') || 'exact';
if (behavior !== 'exact' && behavior !== 'latest-patch') {
throw new Error(
`Invalid go-version-file-behavior: '${behavior}'. Supported values: 'exact', 'latest-patch'`
);
}
if (version && versionFilePath) {
core.warning(
'Both go-version and go-version-file inputs are specified, only go-version will be used'
@ -163,7 +187,7 @@ function resolveVersionInput(): string {
}
if (version) {
return version;
return {version, latestPatchApplied: false};
}
if (versionFilePath) {
@ -172,25 +196,39 @@ function resolveVersionInput(): string {
`The specified go version file at: ${versionFilePath} does not exist`
);
}
version = installer.parseGoVersionFile(versionFilePath);
const versionFile = installer.parseGoVersionFile(versionFilePath);
version = versionFile.version;
const behavior = core.getInput('go-version-file-behavior') || 'exact';
if (behavior === 'latest-patch') {
if (behavior === 'latest-patch' && version) {
if (versionFile.fromToolchainDirective) {
core.info(
`Using toolchain directive version ${version} as written (latest-patch does not widen an explicit toolchain pin)`
);
} else {
const spec = installer.latestPatchSpec(version);
if (spec !== version) {
if (spec === version) {
core.info(
`Using version ${version} as written (latest-patch only widens exact major.minor.patch versions)`
);
} else {
if (
core.getInput('go-download-base-url') ||
process.env['GO_DOWNLOAD_BASE_URL']
) {
throw new Error(
`go-version-file-behavior: 'latest-patch' is not supported with a custom download base URL because version ranges cannot be resolved against it. Use the default 'exact' behavior.`
);
}
core.info(
`Using latest patch release satisfying ${spec} (version file specifies ${version})`
);
version = spec;
return {version: spec, latestPatchApplied: true};
}
}
} else if (behavior !== 'exact') {
throw new Error(
`Invalid go-version-file-behavior: '${behavior}'. Supported values: 'exact', 'latest-patch'`
);
}
}
return version;
return {version, latestPatchApplied: false};
}
function setGoToolchain() {