diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index db6d805..671b3ff 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,7 +18,7 @@ jobs: strategy: matrix: os: [macos-latest, windows-latest, ubuntu-latest] - version: [2.11.27, latest] + version: ["2.11.27", "2", "2.11", "latest"] steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -49,10 +49,19 @@ jobs: env: KOSLI_VERSION_EXPECTED: ${{ matrix.version }} run: | - import sys, os - sys.exit( - int(not os.environ["KOSLI_VERSION_EXPECTED"] in os.environ["KOSLI_VERSION_INSTALLED"]) - ) + import os, sys + expected = os.environ["KOSLI_VERSION_EXPECTED"].strip() + installed = os.environ["KOSLI_VERSION_INSTALLED"].strip().splitlines()[0].strip() + # `kosli version --short` prints a leading "v" (e.g. v2.11.27); normalise it. + if installed.startswith("v"): + installed = installed[1:] + # Exact pin: installed must equal it. Major/minor pin (e.g. "2" or "2.11"): + # installed must sit inside that line, which also proves we never crossed + # into a higher major. + ok = installed == expected or installed.startswith(expected + ".") + if not ok: + print(f"expected version {expected!r}, but installed {installed!r}") + sys.exit(1) - name: Verify latest resolved to a semver if: matrix.version == 'latest' diff --git a/README.md b/README.md index 46493e3..ab76873 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Setup the `kosli` CLI (installs the latest release by default): ```yaml steps: -- uses: kosli-dev/setup-cli-action@v3 +- uses: kosli-dev/setup-cli-action@v5 ``` A specific version of the `kosli` CLI can be installed: @@ -23,17 +23,45 @@ A specific version of the `kosli` CLI can be installed: ```yaml steps: - name: setup-kosli-cli - uses: kosli-dev/setup-cli-action@v3 + uses: kosli-dev/setup-cli-action@v5 with: version: 2.11.43 ``` +### Pin to a major or minor version + +To track a major version and pick up every update within it without ever jumping to +the next (breaking) major, pass just the major number. `version: "2"` always installs +the newest stable `2.x` release, and never `3.0.0`: + +```yaml +steps: +- name: setup-kosli-cli + uses: kosli-dev/setup-cli-action@v5 + with: + version: "2" # newest stable 2.x, never 3.x +``` + +You can pin a minor line the same way. `version: "2.11"` installs the newest stable +`2.11.z` patch: + +```yaml +steps: +- name: setup-kosli-cli + uses: kosli-dev/setup-cli-action@v5 + with: + version: "2.11" +``` + +> **Quote the version.** In YAML, `version: 2.10` is parsed as the number `2.1`, which +> is not what you mean. Always quote a major or minor pin: `version: "2"`, `version: "2.10"`. + To explicitly pin to the newest published release at runtime, pass `latest`: ```yaml steps: - name: setup-kosli-cli - uses: kosli-dev/setup-cli-action@v3 + uses: kosli-dev/setup-cli-action@v5 with: version: latest ``` @@ -42,12 +70,22 @@ steps: The action supports the following inputs: -- `version`: The version of `kosli` to install. Accepts a semver (e.g. `2.11.43`) or the alias `latest`, which resolves to the newest GitHub release of `kosli-dev/cli` at runtime. Defaults to `latest`. -- `github-token`: Token used to authenticate the GitHub API call that resolves `latest`. Defaults to `${{ github.token }}`; normally you do not need to set this. +- `version`: The version of `kosli` to install. Accepts: + - a full semver, e.g. `2.11.43`, installed as-is; + - a major pin, e.g. `"2"`, which resolves to the newest stable `2.x` release; + - a major.minor pin, e.g. `"2.11"`, which resolves to the newest stable `2.11.z` release; + - the alias `latest`, which resolves to the newest stable release of `kosli-dev/cli`. + + Major and minor pins resolve at runtime and never select a pre-release or a higher major. + Quote partial versions (see the note above). Defaults to `latest`. +- `github-token`: Token used to authenticate the GitHub API calls that resolve `latest` or a + major/minor pin. Defaults to `${{ github.token }}`; normally you do not need to set this. ## Outputs -- `version`: The resolved `kosli` CLI version that was installed. When `version: latest` is used, this will contain the concrete semver (e.g. `2.12.0`) and can be referenced by later steps via `steps..outputs.version`. +- `version`: The resolved `kosli` CLI version that was installed. When `version` is `latest` or a + major/minor pin, this contains the concrete semver that was selected (e.g. `2.12.0`) and can be + referenced by later steps via `steps..outputs.version`. ## Example job See [Kosli CLI documentation](https://docs.kosli.com/) @@ -74,7 +112,7 @@ jobs: ... - name: Setup kosli - uses: kosli-dev/setup-cli-action@v3 + uses: kosli-dev/setup-cli-action@v5 - name: Attest ECR image provenance run: diff --git a/action.yml b/action.yml index a86828b..ad11ed2 100644 --- a/action.yml +++ b/action.yml @@ -2,11 +2,11 @@ name: setup-kosli-cli description: Install the Kosli CLI on Github Actions runners inputs: version: - description: Version of Kosli CLI. Use `latest` to install the newest release from GitHub. + description: Version of Kosli CLI to install. Accepts a full semver (e.g. 2.11.43) used as-is, a major pin "2" or major.minor pin "2.11" resolved to the newest stable release in that line, or `latest`. Defaults to latest. required: false default: latest github-token: - description: Token used to authenticate GitHub API calls when resolving `latest`. + description: Token used to authenticate GitHub API calls when resolving `latest` or a major/minor pin. required: false default: ${{ github.token }} outputs: diff --git a/src/download.js b/src/download.js index fa572bc..b334846 100644 --- a/src/download.js +++ b/src/download.js @@ -28,20 +28,110 @@ export function getDownloadUrl({ version, platform, arch }) { return `https://github.com/kosli-dev/cli/releases/download/v${version}/${filename}.${extension}`; } +// Classify the `version` input: +// "latest" -> { kind: "latest" } resolve newest stable release +// "2" / "v2" -> { kind: "partial", major: 2 } newest stable 2.x +// "2.11" / "v2.11" -> { kind: "partial", major: 2, minor: 11 } newest stable 2.11.z +// "2.11.43" -> { kind: "exact" } used verbatim, no API call +// anything else -> { kind: "literal" } used verbatim (e.g. "Latest") +export function classifyVersion(version) { + if (version === "latest") { + return { kind: "latest" }; + } + const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version); + if (!match) { + return { kind: "literal" }; + } + const [, major, minor, patch] = match; + if (patch !== undefined) { + return { kind: "exact" }; + } + return { + kind: "partial", + major: Number(major), + minor: minor === undefined ? undefined : Number(minor) + }; +} + +function compareSemver(a, b) { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) { + return a[i] - b[i]; + } + } + return 0; +} + +// Highest stable "X.Y.Z" within the given major (and optional minor), or null if +// none match. Drafts, pre-releases and non-semver tags are ignored. Comparison is +// numeric so 2.27.3 ranks above 2.9.0. +function highestStableRelease(releases, major, minor) { + let best = null; + for (const release of releases) { + if (release.draft || release.prerelease) { + continue; + } + const parsed = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(release.tag_name || ""); + if (!parsed) { + continue; + } + const candidate = [Number(parsed[1]), Number(parsed[2]), Number(parsed[3])]; + if (candidate[0] !== major) { + continue; + } + if (minor !== undefined && candidate[1] !== minor) { + continue; + } + if (best === null || compareSemver(candidate, best) > 0) { + best = candidate; + } + } + return best === null ? null : best.join("."); +} + export async function resolveVersion(version, token, octokit) { - if (version !== "latest") { + const spec = classifyVersion(version); + + // A full semver or any literal tag is used exactly as given, with no API call. + if (spec.kind === "exact" || spec.kind === "literal") { return version; } + const client = octokit || github.getOctokit(token); - let release; + + if (spec.kind === "latest") { + 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; + } + + // spec.kind === "partial": newest stable release within the requested major + // (and optional minor). Never selects a pre-release or a higher major. + let releases; try { - release = await client.rest.repos.getLatestRelease({ + releases = await client.paginate(client.rest.repos.listReleases, { owner: "kosli-dev", - repo: "cli" + repo: "cli", + per_page: 100 }); } catch (e) { - throw new Error(`failed to resolve latest Kosli CLI version from GitHub: ${e.message}`); + throw new Error(`failed to resolve Kosli CLI version "${version}" from GitHub: ${e.message}`); + } + + const resolved = highestStableRelease(releases, spec.major, spec.minor); + if (!resolved) { + const target = spec.minor === undefined ? `${spec.major}.x` : `${spec.major}.${spec.minor}.x`; + throw new Error( + `no stable kosli-dev/cli release found matching version "${version}" (looked for ${target})` + ); } - const tag = release.data.tag_name; - return tag.startsWith("v") ? tag.slice(1) : tag; + return resolved; } diff --git a/src/index.js b/src/index.js index 66c5c45..6f39f3f 100644 --- a/src/index.js +++ b/src/index.js @@ -11,12 +11,10 @@ async function setup() { const platform = os.platform(); const arch = os.arch(); - const resolvedVersion = version === "latest" - ? await withRetries( - () => resolveVersion(version, token), - { onRetry: logRetry("resolving latest version") } - ) - : version; + const resolvedVersion = await withRetries( + () => resolveVersion(version, token), + { onRetry: logRetry(`resolving Kosli CLI version "${version}"`) } + ); let pathToCLI = tc.find("kosli", resolvedVersion); if (!pathToCLI) { diff --git a/test/download.test.js b/test/download.test.js index 837d761..1abacb8 100644 --- a/test/download.test.js +++ b/test/download.test.js @@ -81,3 +81,91 @@ test("resolveVersion treats 'Latest' (mixed case) as a literal tag, not an alias const result = await resolveVersion("Latest", "token-unused"); t.is(result, "Latest"); }); + +// --- major / minor version pinning --- + +function fakeReleasesOctokit(releases) { + return { + // Real octokit.paginate(endpoint, params) walks every page; the fake just + // returns the canned list regardless of the endpoint passed in. + paginate: async () => releases, + rest: { + repos: { + listReleases: () => ({ data: releases }) + } + } + }; +} + +const releaseFixture = [ + { tag_name: "v10.0.0", draft: false, prerelease: false }, + { tag_name: "v3.0.0", draft: false, prerelease: false }, + { tag_name: "v2.28.0-rc.1", draft: false, prerelease: true }, + { tag_name: "v2.30.0", draft: true, prerelease: false }, + { tag_name: "v2.27.0", draft: false, prerelease: false }, + { tag_name: "v2.27.3", draft: false, prerelease: false }, + { tag_name: "v2.26.5", draft: false, prerelease: false }, + { tag_name: "v2.9.0", draft: false, prerelease: false }, + { tag_name: "v1.40.0", draft: false, prerelease: false }, + { tag_name: "nightly", draft: false, prerelease: false } +]; + +test("resolveVersion resolves a bare major to the newest stable release in that major", async t => { + const result = await resolveVersion("2", "", fakeReleasesOctokit(releaseFixture)); + t.is(result, "2.27.3"); +}); + +test("resolveVersion accepts a leading v on a major pin", async t => { + const result = await resolveVersion("v2", "", fakeReleasesOctokit(releaseFixture)); + t.is(result, "2.27.3"); +}); + +test("resolveVersion never resolves a major pin to a higher major", async t => { + // The fixture has higher majors (v3.0.0 and v10.0.0); a "2" pin must stay on + // major 2, never the highest available overall. + const result = await resolveVersion("2", "", fakeReleasesOctokit(releaseFixture)); + t.is(result.split(".")[0], "2"); +}); + +test("resolveVersion excludes pre-releases and drafts from a major pin", async t => { + // 2.28.0-rc.1 (prerelease) and 2.30.0 (draft) must be ignored, so 2.27.3 wins. + const result = await resolveVersion("2", "", fakeReleasesOctokit(releaseFixture)); + t.is(result, "2.27.3"); +}); + +test("resolveVersion orders a major pin numerically, not lexically", async t => { + // A lexical sort would rank "2.9.0" above "2.27.3"; numeric ordering must not. + const octokit = fakeReleasesOctokit([ + { tag_name: "v2.9.0", draft: false, prerelease: false }, + { tag_name: "v2.27.3", draft: false, prerelease: false } + ]); + t.is(await resolveVersion("2", "", octokit), "2.27.3"); +}); + +test("resolveVersion resolves a major.minor pin to the newest patch in that line", async t => { + const result = await resolveVersion("2.27", "", fakeReleasesOctokit(releaseFixture)); + t.is(result, "2.27.3"); +}); + +test("resolveVersion resolves a major.minor pin independently of other minors", async t => { + const result = await resolveVersion("2.26", "", fakeReleasesOctokit(releaseFixture)); + t.is(result, "2.26.5"); +}); + +test("resolveVersion throws a clear error when no stable release matches a partial", async t => { + await t.throwsAsync(resolveVersion("4", "", fakeReleasesOctokit(releaseFixture)), { + message: /no stable kosli-dev\/cli release found matching version "4".*4\.x/ + }); +}); + +test("resolveVersion surfaces a descriptive error when listing releases fails", async t => { + const octokit = { + paginate: async () => { + throw new Error("HTTP 403 rate limit exceeded"); + }, + rest: { repos: { listReleases: () => ({ data: [] }) } } + }; + await t.throwsAsync(resolveVersion("2", "", octokit), { + message: /failed to resolve Kosli CLI version "2".*rate limit/ + }); +});