diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a27c825..3d31874 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: os: [macos-latest, windows-latest, ubuntu-latest] - version: [2.11.27] + version: [2.11.27, latest] steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -27,6 +27,7 @@ jobs: run: npm ci && npm run build - name: Setup kosli CLI + id: setup uses: ./ with: version: ${{ matrix.version }} @@ -38,7 +39,8 @@ jobs: kosli version --short >> $GITHUB_ENV echo "\n" >> $GITHUB_ENV echo 'EOF' >> $GITHUB_ENV - - name: Verify + - name: Verify pinned version + if: matrix.version != 'latest' shell: python env: KOSLI_VERSION_EXPECTED: ${{ matrix.version }} @@ -47,3 +49,18 @@ jobs: sys.exit( int(not os.environ["KOSLI_VERSION_EXPECTED"] in os.environ["KOSLI_VERSION_INSTALLED"]) ) + - name: Verify latest resolved to a semver + if: matrix.version == 'latest' + shell: python + env: + KOSLI_VERSION_RESOLVED: ${{ steps.setup.outputs.version }} + run: | + import os, re, sys + installed = os.environ["KOSLI_VERSION_INSTALLED"].strip() + resolved = os.environ["KOSLI_VERSION_RESOLVED"].strip() + if not re.match(r"^\d+\.\d+\.\d+", resolved): + print(f"resolved version {resolved!r} does not look like semver") + sys.exit(1) + if resolved not in installed: + print(f"resolved {resolved!r} not found in installed {installed!r}") + sys.exit(1) diff --git a/action.yml b/action.yml index 4f38ac1..29fc692 100644 --- a/action.yml +++ b/action.yml @@ -2,9 +2,16 @@ name: setup-kosli-cli description: Install the Kosli CLI on Github Actions runners inputs: version: - description: Version of Kosli CLI + description: Version of Kosli CLI. Use `latest` to install the newest release from GitHub. required: false default: 2.11.43 + github-token: + description: Token used to authenticate GitHub API calls when resolving `latest`. + required: false + default: ${{ github.token }} +outputs: + version: + description: The resolved Kosli CLI version that was installed. branding: icon: 'download-cloud' color: 'blue' diff --git a/src/download.js b/src/download.js index 018b876..59a7ed4 100644 --- a/src/download.js +++ b/src/download.js @@ -1,4 +1,5 @@ const path = require("path"); +const github = require("@actions/github"); // Map node arch to arch in download url // arch in [arm, x32, x64...] (https://nodejs.org/api/os.html#os_os_arch) @@ -28,4 +29,22 @@ function getDownloadUrl({ version, platform, arch }) { return `https://github.com/kosli-dev/cli/releases/download/v${version}/${filename}.${extension}`; } -module.exports = { getDownloadUrl }; +async function resolveVersion(version, token, octokit) { + if (version !== "latest") { + return version; + } + const client = octokit || github.getOctokit(token); + let release; + try { + release = await client.rest.repos.getLatestRelease({ + owner: "kosli-dev", + repo: "cli" + }); + } catch (e) { + throw new Error(`failed to resolve latest Kosli CLI version from GitHub: ${e.message}`); + } + const tag = release.data.tag_name; + return tag.startsWith("v") ? tag.slice(1) : tag; +} + +module.exports = { getDownloadUrl, resolveVersion }; diff --git a/src/index.js b/src/index.js index 8b8186f..1c70d72 100644 --- a/src/index.js +++ b/src/index.js @@ -2,20 +2,31 @@ const path = require("path"); const os = require("os"); const core = require("@actions/core"); const tc = require("@actions/tool-cache"); -const { getDownloadUrl } = require("./download"); +const { getDownloadUrl, resolveVersion } = require("./download"); async function setup() { try { const version = core.getInput("version"); + const token = core.getInput("github-token"); const platform = os.platform(); const arch = os.arch(); - const downloadUrl = getDownloadUrl({ version, platform, arch }); - console.log(`installing Kosli CLI from ${downloadUrl} ...`); - const pathToTarball = await tc.downloadTool(downloadUrl); - const pathToCLI = await tc.extractTar(pathToTarball); + const resolvedVersion = await resolveVersion(version, token); + + let pathToCLI = tc.find("kosli", resolvedVersion); + if (!pathToCLI) { + const downloadUrl = getDownloadUrl({ version: resolvedVersion, platform, arch }); + console.log(`installing Kosli CLI from ${downloadUrl} ...`); + const pathToTarball = await tc.downloadTool(downloadUrl); + const extracted = await tc.extractTar(pathToTarball); + pathToCLI = await tc.cacheDir(extracted, "kosli", resolvedVersion); + } else { + console.log(`using cached Kosli CLI v${resolvedVersion} from ${pathToCLI}`); + } + core.addPath(pathToCLI); - console.log(`installed Kosli CLI v${version} to ${pathToCLI}`); + core.setOutput("version", resolvedVersion); + console.log(`installed Kosli CLI v${resolvedVersion} to ${pathToCLI}`); } catch (e) { core.setFailed(e); } diff --git a/test/download.test.js b/test/download.test.js index 4efcf4e..1d46177 100644 --- a/test/download.test.js +++ b/test/download.test.js @@ -1,5 +1,5 @@ const test = require("ava"); -const { getDownloadUrl } = require("../src/download"); +const { getDownloadUrl, resolveVersion } = require("../src/download"); const baseUrl = "https://github.com/kosli-dev/cli/releases/download/"; const testCases = [ @@ -19,3 +19,65 @@ testCases.forEach(element => { t.is(res, baseUrl + expected); }); }); + +function fakeOctokit(response) { + return { + rest: { + repos: { + getLatestRelease: async ({ owner, repo }) => { + if (typeof response === "function") { + return response({ owner, repo }); + } + return response; + } + } + } + }; +} + +test("resolveVersion passes through a concrete semver unchanged", async t => { + const result = await resolveVersion("2.11.43", "token-unused"); + t.is(result, "2.11.43"); +}); + +test("resolveVersion with 'latest' strips the leading v from the release tag", async t => { + const octokit = fakeOctokit({ data: { tag_name: "v2.12.0" } }); + const result = await resolveVersion("latest", "", octokit); + t.is(result, "2.12.0"); +}); + +test("resolveVersion with 'latest' returns the tag unchanged if there is no leading v", async t => { + const octokit = fakeOctokit({ data: { tag_name: "2.12.0" } }); + const result = await resolveVersion("latest", "", octokit); + t.is(result, "2.12.0"); +}); + +test("resolveVersion with 'latest' requests the kosli-dev/cli repo", async t => { + let captured; + const octokit = fakeOctokit(args => { + captured = args; + return { data: { tag_name: "v9.9.9" } }; + }); + await resolveVersion("latest", "", octokit); + t.deepEqual(captured, { owner: "kosli-dev", repo: "cli" }); +}); + +test("resolveVersion surfaces a descriptive error when the API call fails", async t => { + const octokit = { + rest: { + repos: { + getLatestRelease: async () => { + throw new Error("HTTP 403 rate limit exceeded"); + } + } + } + }; + await t.throwsAsync(resolveVersion("latest", "", octokit), { + message: /failed to resolve latest Kosli CLI version.*rate limit/ + }); +}); + +test("resolveVersion treats 'Latest' (mixed case) as a literal tag, not an alias", async t => { + const result = await resolveVersion("Latest", "token-unused"); + t.is(result, "Latest"); +});