feat: validate semantic version and refactor patch logic

- Added validation to `resolveKubectlVersion` to ensure input follows "major.minor" or "major.minor.patch" format.
- Moved `getLatestPatchVersion` from `run.ts` to `helpers.ts` to improve code organization and ensure a more robust testing process.
This commit is contained in:
Ogheneobukome Ejaife 2025-07-02 16:19:07 -04:00
parent 77e32d91cd
commit e2483b9d19
No known key found for this signature in database
GPG key ID: F29F0EA1A151DA11
3 changed files with 65 additions and 52 deletions

View file

@ -1,6 +1,8 @@
import * as os from 'os'
import * as util from 'util'
import * as fs from 'fs'
import * as core from '@actions/core'
import * as toolCache from '@actions/tool-cache'
export function getKubectlArch(): string {
const arch = os.arch()
if (arch === 'x64') {
@ -23,6 +25,29 @@ export function getkubectlDownloadURL(version: string, arch: string): string {
}
}
export async function getLatestPatchVersion(
major: string,
minor: string
): Promise<string> {
const sourceURL = `https://cdn.dl.k8s.io/release/stable-${major}.${minor}.txt`
try {
const downloadPath = await toolCache.downloadTool(sourceURL)
const latestPatch = fs
.readFileSync(downloadPath, 'utf8')
.toString()
.trim()
if (!latestPatch) {
throw new Error(`No patch version found for ${major}.${minor}`)
}
return latestPatch
} catch (error) {
core.debug(error)
core.warning('GetLatestPatchVersionFailed')
throw new Error(
`Failed to get latest patch version for ${major}.${minor}`
)
}
}
export function getExecutableExtension(): string {
if (os.type().match(/^Win/)) {
return '.exe'

View file

@ -2,7 +2,8 @@ import * as run from './run'
import {
getkubectlDownloadURL,
getKubectlArch,
getExecutableExtension
getExecutableExtension,
getLatestPatchVersion
} from './helpers'
import * as os from 'os'
import * as toolCache from '@actions/tool-cache'
@ -11,7 +12,11 @@ import * as path from 'path'
import * as core from '@actions/core'
import * as util from 'util'
describe('Testing all functions in run file.', () => {
beforeEach(() => {
jest.clearAllMocks()
})
test('getExecutableExtension() - return .exe when os is Windows', () => {
jest.spyOn(os, 'type').mockReturnValue('Windows_NT')
expect(getExecutableExtension()).toBe('.exe')
@ -164,28 +169,27 @@ describe('Testing all functions in run file.', () => {
)
expect(toolCache.downloadTool).not.toHaveBeenCalled()
})
test('getLatestPatchVersion() - download and return latest patch version', async () => {
jest
.spyOn(toolCache, 'downloadTool')
.mockReturnValue(Promise.resolve('pathToTool'))
.mockResolvedValue('pathToTool')
jest.spyOn(fs, 'readFileSync').mockReturnValue('v1.27.15')
const result = await run.getLatestPatchVersion('1', '27')
const result = await getLatestPatchVersion('1', '27')
expect(result).toBe('v1.27.15')
expect(toolCache.downloadTool).toHaveBeenCalledWith(
'https://cdn.dl.k8s.io/release/stable-1.27.txt'
)
expect(fs.readFileSync).toHaveBeenCalledWith('pathToTool', 'utf8')
})
test('getLatestPatchVersion() - throw error when patch version is empty', async () => {
jest
.spyOn(toolCache, 'downloadTool')
.mockReturnValue(Promise.resolve('pathToTool'))
.mockResolvedValue('pathToTool')
jest.spyOn(fs, 'readFileSync').mockReturnValue('')
await expect(run.getLatestPatchVersion('1', '27')).rejects.toThrow(
await expect(getLatestPatchVersion('1', '27')).rejects.toThrow(
'Failed to get latest patch version for 1.27'
)
})
@ -195,35 +199,36 @@ describe('Testing all functions in run file.', () => {
.spyOn(toolCache, 'downloadTool')
.mockRejectedValue(new Error('Network error'))
await expect(run.getLatestPatchVersion('1', '27')).rejects.toThrow(
await expect(getLatestPatchVersion('1', '27')).rejects.toThrow(
'Failed to get latest patch version for 1.27'
)
})
test('resolveKubectlVersion() - expands major.minor to latest patch', async () => {
// Mock the getLatestPatchVersion call
jest.spyOn(run, 'getLatestPatchVersion').mockResolvedValue('v1.27.15')
jest
.spyOn(toolCache, 'downloadTool')
.mockResolvedValue('pathToTool')
jest.spyOn(fs, 'readFileSync').mockReturnValue('v1.27.15')
const result = await run.resolveKubectlVersion('1.27')
expect(result).toBe('v1.27.15')
expect(run.getLatestPatchVersion).toHaveBeenCalledWith('1', '27')
})
test('resolveKubectlVersion() - returns full version unchanged', async () => {
const result = await run.resolveKubectlVersion('v1.27.15')
expect(result).toBe('v1.27.15')
})
test('resolveKubectlVersion() - adds v prefix to full version', async () => {
const result = await run.resolveKubectlVersion('1.27.15')
expect(result).toBe('v1.27.15')
})
test('resolveKubectlVersion() - expands v-prefixed major.minor to latest patch', async () => {
jest.spyOn(run, 'getLatestPatchVersion').mockResolvedValue('v1.27.15')
jest
.spyOn(toolCache, 'downloadTool')
.mockResolvedValue('pathToTool')
jest.spyOn(fs, 'readFileSync').mockReturnValue('v1.27.15')
const result = await run.resolveKubectlVersion('v1.27')
expect(result).toBe('v1.27.15')
expect(run.getLatestPatchVersion).toHaveBeenCalledWith('1', '27')
})
test('run() - download specified version and set output', async () => {
jest.spyOn(core, 'getInput').mockReturnValue('v1.15.5')

View file

@ -1,14 +1,13 @@
import * as path from 'path'
import * as util from 'util'
import * as fs from 'fs'
import * as semver from 'semver'
import * as toolCache from '@actions/tool-cache'
import * as runModule from './run'
import * as core from '@actions/core'
import {
getkubectlDownloadURL,
getKubectlArch,
getExecutableExtension
getExecutableExtension,
getLatestPatchVersion
} from './helpers'
const kubectlToolName = 'kubectl'
@ -92,43 +91,27 @@ export async function downloadKubectl(version: string): Promise<string> {
return kubectlPath
}
export async function getLatestPatchVersion(
major: string,
minor: string
): Promise<string> {
const sourceURL = `https://cdn.dl.k8s.io/release/stable-${major}.${minor}.txt`
try {
const downloadPath = await toolCache.downloadTool(sourceURL)
const latestPatch = fs
.readFileSync(downloadPath, 'utf8')
.toString()
.trim()
if (!latestPatch) {
throw new Error(`No patch version found for ${major}.${minor}`)
}
return latestPatch
} catch (error) {
core.debug(error)
core.warning('GetLatestPatchVersionFailed')
throw new Error(
`Failed to get latest patch version for ${major}.${minor}`
)
}
}
export async function resolveKubectlVersion(version: string): Promise<string> {
const cleanedVersion = version.trim()
/*------ detect "major.minor" only ----------------*/
const mmMatch = cleanedVersion.match(/^v?(?<major>\d+)\.(?<minor>\d+)$/)
if (!mmMatch || !mmMatch.groups) {
// User already provided a full version such as 1.27.15 do nothing.
if (mmMatch?.groups) {
const {major, minor} = mmMatch.groups
// Call the k8s CDN to get the latest patch version for the given major.minor
return await getLatestPatchVersion(major, minor)
}
/*------ detect "major.minor.patch" and validate ----------------*/
const mmpMatch = cleanedVersion.match(/^v?(\d+\.\d+\.\d+)$/)
if (!mmpMatch) {
throw new Error(
`Invalid version format: "${version}". Version must be in "major.minor" or "major.minor.patch" format (e.g., "1.27" or "v1.27.15").`
)
}
// User provided a full version such as 1.27.15, ensure it has a 'v' prefix.
return cleanedVersion.startsWith('v')
? cleanedVersion
: `v${cleanedVersion}`
}
const {major, minor} = mmMatch.groups
// Call the k8s CDN to get the latest patch version for the given major.minor
return await runModule.getLatestPatchVersion(major, minor)
}