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

@ -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') {
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() {