diff --git a/README.md b/README.md index 0b271877..5f38c1c8 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,25 @@ Acceptable values are latest or any semantic version string like v3.5.0 Use this id: install ``` +Alternatively, the version can be read from a [`.tool-versions`](https://asdf-vm.com/manage/configuration.html) file (the format used by [asdf](https://asdf-vm.com/) and [mise](https://mise.jdx.dev/)) via the `version-file` input: + +```yaml +- uses: azure/setup-helm@v5.0.0 + with: + version-file: .tool-versions + id: install +``` + +The action reads the version declared for the `helm` tool, for example: + +``` +helm 3.18.4 +``` + +If both `version` and `version-file` are set, an explicitly requested `version` takes precedence and `version-file` is ignored (a warning is emitted). Because `version` defaults to `latest`, `version-file` is only ignored when you set `version` to a specific value other than `latest`; if `version` is left at its default, the version from `version-file` is used. + > [!NOTE] -> If something goes wrong with fetching the latest version the action will use the hardcoded default version (currently v3.18.3). If you rely on a certain version higher than the default, you should explicitly use that version instead of latest. +> If something goes wrong with fetching the latest version the action will use the hardcoded default version (currently v3.18.4). If you rely on a certain version higher than the default, you should explicitly use that version instead of latest. The cached helm binary path is prepended to the PATH environment variable as well as stored in the helm-path output variable. Refer to the action metadata file for details about all the inputs https://github.com/Azure/setup-helm/blob/master/action.yml diff --git a/action.yml b/action.yml index 39d8caec..572af494 100644 --- a/action.yml +++ b/action.yml @@ -3,8 +3,11 @@ description: 'Install a specific version of helm binary. Acceptable values are l inputs: version: description: 'Version of helm' - required: true + required: false default: 'latest' + version-file: + description: 'Path to a .tool-versions file to read the helm version from' + required: false token: description: GitHub token. Used to be required to fetch the latest version required: false diff --git a/src/run.test.ts b/src/run.test.ts index 01f2fd00..30b313d2 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -19,7 +19,8 @@ vi.mock('fs', async (importOriginal) => { readdirSync: vi.fn(), statSync: vi.fn(), chmodSync: vi.fn(), - readFileSync: vi.fn() + readFileSync: vi.fn(), + existsSync: vi.fn() } }) @@ -161,6 +162,140 @@ describe('run.ts', () => { expect(run.getValidVersion('3.8.0')).toBe('v3.8.0') }) + test('parseToolVersions() - return the helm version from .tool-versions content', () => { + const content = ['nodejs 20.11.0', 'helm 3.14.0', 'terraform 1.7.0'].join( + '\n' + ) + expect(run.parseToolVersions(content)).toBe('3.14.0') + }) + + test('parseToolVersions() - ignore comments and blank lines', () => { + const content = ['# tools', '', ' helm 3.15.2 ', ''].join('\n') + expect(run.parseToolVersions(content)).toBe('3.15.2') + }) + + test('parseToolVersions() - return the first version when several are listed', () => { + expect(run.parseToolVersions('helm 3.14.0 3.13.0')).toBe('3.14.0') + }) + + test('parseToolVersions() - return empty string when helm is not declared', () => { + expect(run.parseToolVersions('nodejs 20.11.0')).toBe('') + }) + + test('getVersionFromToolVersionsFile() - read the helm version from a file', () => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('helm 3.14.0') + + expect(run.getVersionFromToolVersionsFile('.tool-versions')).toBe( + '3.14.0' + ) + expect(fs.readFileSync).toHaveBeenCalledWith('.tool-versions', 'utf8') + }) + + test('getVersionFromToolVersionsFile() - throw when the file does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + + expect(() => + run.getVersionFromToolVersionsFile('missing.tool-versions') + ).toThrow("The version-file 'missing.tool-versions' does not exist") + }) + + test('getVersionFromToolVersionsFile() - throw when no helm version is present', () => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('nodejs 20.11.0') + + expect(() => + run.getVersionFromToolVersionsFile('.tool-versions') + ).toThrow("No helm version found in '.tool-versions'") + }) + + test('getVersionFromToolVersionsFile() - throw when the helm version is not semver-shaped', () => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('helm latest') + + expect(() => + run.getVersionFromToolVersionsFile('.tool-versions') + ).toThrow( + "The helm version 'latest' in '.tool-versions' is not a valid semantic version" + ) + }) + + test('isSemVerShaped() - accept semver-shaped versions with or without a v prefix', () => { + expect(run.isSemVerShaped('3.14.0')).toBe(true) + expect(run.isSemVerShaped('v3.14.0')).toBe(true) + expect(run.isSemVerShaped('3.14.0-rc.1')).toBe(true) + }) + + test('isSemVerShaped() - reject values that are not semver-shaped', () => { + expect(run.isSemVerShaped('latest')).toBe(false) + expect(run.isSemVerShaped('3.14')).toBe(false) + expect(run.isSemVerShaped('abc')).toBe(false) + }) + + // Stubs the download chain so run() resolves to a cached helm binary, + // letting these tests focus on version-vs-version-file resolution. + const stubDownloadChain = () => { + vi.mocked(os.platform).mockReturnValue('linux') + vi.mocked(os.arch).mockReturnValue('x64') + vi.mocked(toolCache.find).mockReturnValue('pathToCachedDir') + vi.mocked(fs.chmodSync).mockImplementation(() => {}) + vi.mocked(fs.readdirSync).mockReturnValue([ + 'helm' as unknown as fs.Dirent + ]) + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => false + } as fs.Stats) + } + + const inputs = (version: string, versionFile: string) => + vi.mocked(core.getInput).mockImplementation((name: string) => { + if (name === 'version') return version + if (name === 'version-file') return versionFile + if (name === 'downloadBaseURL') return downloadBaseURL + return '' + }) + + test('run() - resolve the version from version-file when version is not set', async () => { + stubDownloadChain() + inputs('', '.tool-versions') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('helm 3.14.0') + + await run.run() + + expect(core.warning).not.toHaveBeenCalled() + expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.0') + expect(core.setOutput).toHaveBeenCalledWith( + 'helm-path', + path.join('pathToCachedDir', 'helm') + ) + }) + + test('run() - resolve the version from version-file when version is left at the latest default', async () => { + stubDownloadChain() + inputs('latest', '.tool-versions') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('helm 3.14.0') + + await run.run() + + expect(core.warning).not.toHaveBeenCalled() + expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.14.0') + }) + + test('run() - warn and prefer version over version-file when both are set', async () => { + stubDownloadChain() + inputs('3.5.0', '.tool-versions') + + await run.run() + + expect(core.warning).toHaveBeenCalledWith( + `Both 'version' and 'version-file' inputs are specified, only 'version' will be used.` + ) + expect(fs.readFileSync).not.toHaveBeenCalled() + expect(toolCache.find).toHaveBeenCalledWith('helm', 'v3.5.0') + }) + test('walkSync() - return path to the all files matching fileToFind in dir', () => { vi.mocked(fs.readdirSync).mockImplementation((file, _?) => { if (file == 'mainFolder') diff --git a/src/run.ts b/src/run.ts index 9554797b..ff7c2857 100644 --- a/src/run.ts +++ b/src/run.ts @@ -13,11 +13,27 @@ const helmToolName = 'helm' export const stableHelmVersion = 'v3.18.4' export async function run() { - let version = core.getInput('version', {required: true}) + let version = core.getInput('version') + const versionFile = core.getInput('version-file') + + if (versionFile) { + if (version && version !== 'latest') { + core.warning( + `Both 'version' and 'version-file' inputs are specified, only 'version' will be used.` + ) + } else { + version = getVersionFromToolVersionsFile(versionFile) + core.info(`Resolved Helm version '${version}' from '${versionFile}'`) + } + } + + if (!version) { + version = 'latest' + } if (version !== 'latest' && version[0] !== 'v') { - core.info('Getting latest Helm version') version = getValidVersion(version) + core.info(`Normalized Helm version to '${version}'`) } if (version.toLocaleLowerCase() === 'latest') { version = await getLatestHelmVersion() @@ -46,6 +62,51 @@ export function getValidVersion(version: string): string { return 'v' + version } +// Matches a semantic version (major.minor.patch) with an optional leading 'v' +// and optional pre-release / build-metadata suffixes, e.g. '3.14.0', 'v3.14.0', +// '3.14.0-rc.1'. +const semVerShape = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ + +// Returns true when version looks like a semantic version +export function isSemVerShaped(version: string): boolean { + return semVerShape.test(version) +} + +// Reads a .tool-versions file and returns the helm version declared in it +export function getVersionFromToolVersionsFile(filePath: string): string { + if (!fs.existsSync(filePath)) { + throw new Error(`The version-file '${filePath}' does not exist`) + } + const content = fs.readFileSync(filePath, 'utf8') + const version = parseToolVersions(content) + if (!version) { + throw new Error(`No helm version found in '${filePath}'`) + } + if (!isSemVerShaped(version)) { + throw new Error( + `The helm version '${version}' in '${filePath}' is not a valid semantic version` + ) + } + return version +} + +// Parses .tool-versions content (asdf/mise format) and returns the first +// helm version, or an empty string when none is declared. Lines look like +// `helm 3.14.0`; comments (#) and blank lines are ignored. +export function parseToolVersions(content: string): string { + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + const [tool, version] = trimmed.split(/\s+/) + if (tool === helmToolName && version) { + return version + } + } + return '' +} + // Gets the latest helm version or returns a default stable if getting latest fails export async function getLatestHelmVersion(): Promise { try {