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 os from 'os'
import * as util from 'util' 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 { export function getKubectlArch(): string {
const arch = os.arch() const arch = os.arch()
if (arch === 'x64') { 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 { export function getExecutableExtension(): string {
if (os.type().match(/^Win/)) { if (os.type().match(/^Win/)) {
return '.exe' return '.exe'

View file

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

View file

@ -1,14 +1,13 @@
import * as path from 'path' import * as path from 'path'
import * as util from 'util' import * as util from 'util'
import * as fs from 'fs' import * as fs from 'fs'
import * as semver from 'semver'
import * as toolCache from '@actions/tool-cache' import * as toolCache from '@actions/tool-cache'
import * as runModule from './run'
import * as core from '@actions/core' import * as core from '@actions/core'
import { import {
getkubectlDownloadURL, getkubectlDownloadURL,
getKubectlArch, getKubectlArch,
getExecutableExtension getExecutableExtension,
getLatestPatchVersion
} from './helpers' } from './helpers'
const kubectlToolName = 'kubectl' const kubectlToolName = 'kubectl'
@ -92,43 +91,27 @@ export async function downloadKubectl(version: string): Promise<string> {
return kubectlPath 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> { export async function resolveKubectlVersion(version: string): Promise<string> {
const cleanedVersion = version.trim() const cleanedVersion = version.trim()
/*------ detect "major.minor" only ----------------*/ /*------ detect "major.minor" only ----------------*/
const mmMatch = cleanedVersion.match(/^v?(?<major>\d+)\.(?<minor>\d+)$/) const mmMatch = cleanedVersion.match(/^v?(?<major>\d+)\.(?<minor>\d+)$/)
if (!mmMatch || !mmMatch.groups) { if (mmMatch?.groups) {
// User already provided a full version such as 1.27.15 do nothing. const {major, minor} = mmMatch.groups
return cleanedVersion.startsWith('v') // Call the k8s CDN to get the latest patch version for the given major.minor
? cleanedVersion return await getLatestPatchVersion(major, minor)
: `v${cleanedVersion}`
} }
const {major, minor} = mmMatch.groups
// Call the k8s CDN to get the latest patch version for the given major.minor /*------ detect "major.minor.patch" and validate ----------------*/
return await runModule.getLatestPatchVersion(major, minor) 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}`
} }