From f75efb3f19c443a60dd9b11cd11afe327a61c171 Mon Sep 17 00:00:00 2001 From: John Maguire Date: Tue, 18 Aug 2026 13:28:12 -0400 Subject: [PATCH] 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 --- __tests__/setup-go.test.ts | 88 ++++++++++++++++++++++++++++++++++++++ action.yml | 2 +- dist/setup/index.js | 78 +++++++++++++++++++++++---------- docs/advanced-usage.md | 15 ++++++- src/installer.ts | 42 ++++++++++++++---- src/main.ts | 72 +++++++++++++++++++++++-------- 6 files changed, 246 insertions(+), 51 deletions(-) diff --git a/__tests__/setup-go.test.ts b/__tests__/setup-go.test.ts index dccde7f..49c4cab 100644 --- a/__tests__/setup-go.test.ts +++ b/__tests__/setup-go.test.ts @@ -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).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'], diff --git a/action.yml b/action.yml index f5b85e6..b47232e 100644 --- a/action.yml +++ b/action.yml @@ -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' diff --git a/dist/setup/index.js b/dist/setup/index.js index fd9ee75..3f4011f 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -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 spec = latestPatchSpec(version); - if (spec !== version) { - core_info(`Using latest patch release satisfying ${spec} (version file specifies ${version})`); - version = spec; + 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) { + 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})`); + 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 diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 268ac58..85f3387 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -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 diff --git a/src/installer.ts b/src/installer.ts index 92ea632..e62d239 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -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( diff --git a/src/main.ts b/src/main.ts index f68d057..de52a85 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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') { - const spec = installer.latestPatchSpec(version); - if (spec !== version) { + if (behavior === 'latest-patch' && version) { + if (versionFile.fromToolchainDirective) { core.info( - `Using latest patch release satisfying ${spec} (version file specifies ${version})` + `Using toolchain directive version ${version} as written (latest-patch does not widen an explicit toolchain pin)` ); - version = spec; + } else { + const spec = installer.latestPatchSpec(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})` + ); + 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() {