Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"):
Comment thread
AlexKantor87 marked this conversation as resolved.
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'
Expand Down
52 changes: 45 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,53 @@ 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:

```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
```
Expand All @@ -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.<id>.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.<id>.outputs.version`.

## Example job
See [Kosli CLI documentation](https://docs.kosli.com/)
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
104 changes: 97 additions & 7 deletions src/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
10 changes: 4 additions & 6 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
88 changes: 88 additions & 0 deletions test/download.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/
});
});
Loading