From e2483b9d19bee1a8bc49bc90d7a011f06fc8377b Mon Sep 17 00:00:00 2001 From: Ogheneobukome Ejaife Date: Wed, 2 Jul 2025 16:19:07 -0400 Subject: [PATCH] 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. --- src/helpers.ts | 27 ++++++++++++++++++++++++- src/run.test.ts | 37 +++++++++++++++++++--------------- src/run.ts | 53 +++++++++++++++++-------------------------------- 3 files changed, 65 insertions(+), 52 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index a0838f6..76c1013 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -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 { + 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' diff --git a/src/run.test.ts b/src/run.test.ts index adfe64d..ce29350 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -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') diff --git a/src/run.ts b/src/run.ts index 8c21920..76f776f 100644 --- a/src/run.ts +++ b/src/run.ts @@ -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 { return kubectlPath } -export async function getLatestPatchVersion( - major: string, - minor: string -): Promise { - 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 { const cleanedVersion = version.trim() /*------ detect "major.minor" only ----------------*/ const mmMatch = cleanedVersion.match(/^v?(?\d+)\.(?\d+)$/) - if (!mmMatch || !mmMatch.groups) { - // User already provided a full version such as 1.27.15 – do nothing. - return cleanedVersion.startsWith('v') - ? cleanedVersion - : `v${cleanedVersion}` + 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) } - 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) + /*------ 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}` }