diff --git a/.github/workflows/data/local/.opentofu-version b/.github/workflows/data/local/.opentofu-version new file mode 100644 index 0000000..a0f9a4b --- /dev/null +++ b/.github/workflows/data/local/.opentofu-version @@ -0,0 +1 @@ +latest diff --git a/.github/workflows/setup-tofu.yml b/.github/workflows/setup-tofu.yml index a38a6b4..4346502 100644 --- a/.github/workflows/setup-tofu.yml +++ b/.github/workflows/setup-tofu.yml @@ -14,6 +14,25 @@ env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: + tofu-version-files: + name: 'OpenTofu Version Files' + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + tofu-version-files: ['./.github/workflows/data/local/.opentofu-version'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup OpenTofu - ${{ matrix['tofu-version-files'] }} + uses: ./ + with: + tofu_version_file: ${{ matrix['tofu-version-files'] }} + tofu_wrapper: false + + - name: Validate that OpenTofu was installed + run: tofu version | grep 'OpenTofu v' tofu-versions: name: 'OpenTofu Versions' runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index 055cc91..55456e5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,17 @@ steps: tofu_version: 1.6.0 ``` +You can also specify the version in a file (e.g., `.opentofu-version`): + +```yaml +steps: + - uses: opentofu/setup-opentofu@v1 + with: + tofu_version_file: .opentofu-version +``` + +Supported version syntax is the same as for the `tofu_version` input. If both `tofu_version` and `tofu_version_file` are provided, the version number in the file takes precedence. + Credentials for Terraform Cloud ([app.terraform.io](https://app.terraform.io/)) can be configured: ```yaml @@ -248,6 +259,8 @@ The action supports the following inputs: for available range specifications). Examples are: `<1.6.0-beta`, `~1.6.0-alpha`, `1.6.0-alpha2` (all three installing the latest available `1.6.0-alpha2` version). Prerelease versions can be specified and a range will stay within the given tag such as `beta` or `rc`. If no version is given, it will default to `latest`. +- `tofu_version_file` - (optional) Path to a file containing the OpenTofu version to install. Supported version syntax + is the same as for the `tofu_version` input. Takes precedence over `tofu_version` if both are provided. - `tofu_wrapper` - (optional) Whether to install a wrapper to wrap subsequent calls of the `tofu` binary and expose its STDOUT, STDERR, and exit code as outputs named `stdout`, `stderr`, and `exitcode` respectively. Defaults to `true`. diff --git a/action.yml b/action.yml index 2a0497d..4f9056a 100644 --- a/action.yml +++ b/action.yml @@ -13,6 +13,9 @@ inputs: description: 'The version of OpenTofu CLI to install. If no version is given, it will default to `latest`.' default: 'latest' required: false + tofu_version_file: + description: 'Path to a file containing the OpenTofu version to install. Takes precedence over `tofu_version` if both are provided.' + required: false tofu_wrapper: description: 'Whether or not to install a wrapper to wrap subsequent calls of the `tofu` binary and expose its STDOUT, STDERR, and exit code as outputs named `stdout`, `stderr`, and `exitcode` respectively. Defaults to `true`.' default: 'true' diff --git a/dist/index.js b/dist/index.js index 578bb62..6c29c1f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -247,7 +247,8 @@ credentials "${credentialsHostname}" { async function run () { try { // Gather GitHub Actions inputs - const version = core.getInput('tofu_version'); + let version = core.getInput('tofu_version'); + const versionFile = core.getInput('tofu_version_file'); const credentialsHostname = core.getInput('cli_config_credentials_hostname'); const credentialsToken = core.getInput('cli_config_credentials_token'); const wrapper = core.getInput('tofu_wrapper') === 'true'; @@ -258,6 +259,27 @@ async function run () { githubToken = process.env.GITHUB_TOKEN; } + // If tofu_version_file is provided, read the version from the file + if (versionFile) { + try { + core.debug(`Reading OpenTofu version from file: ${versionFile}`); + const fileVersion = await fs.readFile(versionFile, 'utf8'); + const trimmedVersion = fileVersion.trim(); + if (trimmedVersion) { + version = trimmedVersion; + core.debug(`Using version from file: ${version}`); + } else { + core.warning( + `Version file ${versionFile} is empty, using tofu_version input: ${version}` + ); + } + } catch (error) { + core.warning( + `Failed to read version from file ${versionFile}: ${error.message}. Using tofu_version input: ${version}` + ); + } + } + // Gather OS details const osPlatform = os.platform(); const osArch = os.arch(); diff --git a/lib/setup-tofu.js b/lib/setup-tofu.js index 0eb7aa1..6e4eb62 100644 --- a/lib/setup-tofu.js +++ b/lib/setup-tofu.js @@ -128,7 +128,8 @@ credentials "${credentialsHostname}" { async function run () { try { // Gather GitHub Actions inputs - const version = core.getInput('tofu_version'); + let version = core.getInput('tofu_version'); + const versionFile = core.getInput('tofu_version_file'); const credentialsHostname = core.getInput('cli_config_credentials_hostname'); const credentialsToken = core.getInput('cli_config_credentials_token'); const wrapper = core.getInput('tofu_wrapper') === 'true'; @@ -139,6 +140,27 @@ async function run () { githubToken = process.env.GITHUB_TOKEN; } + // If tofu_version_file is provided, read the version from the file + if (versionFile) { + try { + core.debug(`Reading OpenTofu version from file: ${versionFile}`); + const fileVersion = await fs.readFile(versionFile, 'utf8'); + const trimmedVersion = fileVersion.trim(); + if (trimmedVersion) { + version = trimmedVersion; + core.debug(`Using version from file: ${version}`); + } else { + core.warning( + `Version file ${versionFile} is empty, using tofu_version input: ${version}` + ); + } + } catch (error) { + core.warning( + `Failed to read version from file ${versionFile}: ${error.message}. Using tofu_version input: ${version}` + ); + } + } + // Gather OS details const osPlatform = os.platform(); const osArch = os.arch(); diff --git a/lib/test/setup-tofu.test.js b/lib/test/setup-tofu.test.js new file mode 100644 index 0000000..ca6d4ab --- /dev/null +++ b/lib/test/setup-tofu.test.js @@ -0,0 +1,176 @@ +// Node.js core +const fs = require('fs').promises; +const os = require('os'); +const path = require('path'); + +// External +const core = require('@actions/core'); + +// First party +const releases = require('../releases'); +const setup = require('../setup-tofu'); + +// Mock dependencies +jest.mock('@actions/core'); +jest.mock('@actions/io', () => ({ + mv: jest.fn(), + cp: jest.fn(), + mkdirP: jest.fn() +})); +jest.mock('@actions/tool-cache', () => ({ + downloadTool: jest.fn(), + extractZip: jest.fn() +})); + +// Mock releases.js so setup-tofu.js can be tested in isolation +jest.mock('../releases'); + +// Set up global test fixtures +const fallbackVersion = 'latest'; +let tempDir; +let tempDirPath; +let version = '1.10.5'; +let versionFile; +let versionFileName = '.opentofu-version'; + +describe('setup-tofu', () => { + beforeAll(async () => { + // Mock dependencies + const tc = require('@actions/tool-cache'); + tc.downloadTool.mockResolvedValue('/mock/download/path'); + tc.extractZip.mockResolvedValue('/mock/extract/path'); + + const io = require('@actions/io'); + io.mv.mockResolvedValue(); + io.cp.mockResolvedValue(); + io.mkdirP.mockResolvedValue(); + + const mockRelease = { + getBuild: jest.fn().mockReturnValue({ url: 'mock-url' }) + }; + releases.getRelease.mockResolvedValue(mockRelease); + + // Write version file to temporary directory + tempDirPath = path.join(os.tmpdir(), 'setup-tofu-'); + tempDir = await fs.mkdtemp(tempDirPath); + versionFile = path.join(tempDir, versionFileName); + await fs.writeFile(versionFile, `${version}\n`); + + // Mock action inputs to return default values + core.getInput.mockImplementation((name) => { + const defaults = { + tofu_version: fallbackVersion, + tofu_version_file: versionFile, + cli_config_credentials_hostname: '', + cli_config_credentials_token: '', + tofu_wrapper: 'true', + github_token: '' + }; + return defaults[name] || ''; + }); + + // Mock environment variables + process.env.GITHUB_TOKEN = 'mock-github-token'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + delete process.env.GITHUB_TOKEN; + }); + + describe('tofu_version_file functionality', () => { + it('should read version from file when tofu_version_file is provided', async () => { + jest.spyOn(fs, 'readFile'); + + await setup(); + + expect(releases.getRelease).toHaveBeenCalledWith( + version, process.env.GITHUB_TOKEN + ); + + expect(fs.readFile).toHaveBeenCalled(); + }); + + it('should handle empty version file gracefully', async () => { + jest.spyOn(fs, 'readFile'); + + version = ' '; + versionFileName = '.opentofu-version-empty'; + versionFile = path.join(tempDir, versionFileName); + await fs.writeFile(versionFile, `${version}\n`); + + core.getInput.mockImplementation((name) => { + if (name === 'tofu_version_file') { + return versionFile; + } + if (name === 'tofu_version') { + return fallbackVersion; + } + return ''; + }); + + await setup(); + + expect(releases.getRelease).toHaveBeenCalledWith( + fallbackVersion, process.env.GITHUB_TOKEN + ); + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining(`Version file ${versionFile} is empty`) + ); + + expect(fs.readFile).toHaveBeenCalled(); + }); + + it('should handle file read errors gracefully', async () => { + jest.spyOn(fs, 'readFile'); + + versionFileName = '.opentofu-version-file-does-not-exist'; + versionFile = path.join(tempDir, versionFileName); + + core.getInput.mockImplementation((name) => { + if (name === 'tofu_version_file') { + return versionFile; + } + if (name === 'tofu_version') { + return fallbackVersion; + } + return ''; + }); + + await setup(); + + expect(releases.getRelease).toHaveBeenCalledWith( + fallbackVersion, process.env.GITHUB_TOKEN + ); + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining(`Failed to read version from file ${versionFile}`) + ); + + expect(fs.readFile).toHaveBeenCalled(); + }); + + it('should not read file when tofu_version_file is not provided', async () => { + jest.spyOn(fs, 'readFile'); + + core.getInput.mockImplementation((name) => { + if (name === 'tofu_version') { + return fallbackVersion; + } + return ''; + }); + + await setup(); + + expect(releases.getRelease).toHaveBeenCalledWith( + fallbackVersion, process.env.GITHUB_TOKEN + ); + + expect(fs.readFile).not.toHaveBeenCalled(); + }); + }); +});