Skip to content

Commit 36ce997

Browse files
authored
Merge pull request #24 from kosli-dev/major-version-pinning
feat: pin the Kosli CLI to a major or minor version
2 parents 5601264 + c223b65 commit 36ce997

6 files changed

Lines changed: 250 additions & 27 deletions

File tree

.github/workflows/test.yaml

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
strategy:
1919
matrix:
2020
os: [macos-latest, windows-latest, ubuntu-latest]
21-
version: [2.11.27, latest]
21+
version: ["2.11.27", "2", "2.11", "latest"]
2222
steps:
2323
- name: Checkout
2424
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -49,10 +49,19 @@ jobs:
4949
env:
5050
KOSLI_VERSION_EXPECTED: ${{ matrix.version }}
5151
run: |
52-
import sys, os
53-
sys.exit(
54-
int(not os.environ["KOSLI_VERSION_EXPECTED"] in os.environ["KOSLI_VERSION_INSTALLED"])
55-
)
52+
import os, sys
53+
expected = os.environ["KOSLI_VERSION_EXPECTED"].strip()
54+
installed = os.environ["KOSLI_VERSION_INSTALLED"].strip().splitlines()[0].strip()
55+
# `kosli version --short` prints a leading "v" (e.g. v2.11.27); normalise it.
56+
if installed.startswith("v"):
57+
installed = installed[1:]
58+
# Exact pin: installed must equal it. Major/minor pin (e.g. "2" or "2.11"):
59+
# installed must sit inside that line, which also proves we never crossed
60+
# into a higher major.
61+
ok = installed == expected or installed.startswith(expected + ".")
62+
if not ok:
63+
print(f"expected version {expected!r}, but installed {installed!r}")
64+
sys.exit(1)
5665
5766
- name: Verify latest resolved to a semver
5867
if: matrix.version == 'latest'

README.md

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,53 @@ Setup the `kosli` CLI (installs the latest release by default):
1515

1616
```yaml
1717
steps:
18-
- uses: kosli-dev/setup-cli-action@v3
18+
- uses: kosli-dev/setup-cli-action@v5
1919
```
2020
2121
A specific version of the `kosli` CLI can be installed:
2222

2323
```yaml
2424
steps:
2525
- name: setup-kosli-cli
26-
uses: kosli-dev/setup-cli-action@v3
26+
uses: kosli-dev/setup-cli-action@v5
2727
with:
2828
version: 2.11.43
2929
```
3030

31+
### Pin to a major or minor version
32+
33+
To track a major version and pick up every update within it without ever jumping to
34+
the next (breaking) major, pass just the major number. `version: "2"` always installs
35+
the newest stable `2.x` release, and never `3.0.0`:
36+
37+
```yaml
38+
steps:
39+
- name: setup-kosli-cli
40+
uses: kosli-dev/setup-cli-action@v5
41+
with:
42+
version: "2" # newest stable 2.x, never 3.x
43+
```
44+
45+
You can pin a minor line the same way. `version: "2.11"` installs the newest stable
46+
`2.11.z` patch:
47+
48+
```yaml
49+
steps:
50+
- name: setup-kosli-cli
51+
uses: kosli-dev/setup-cli-action@v5
52+
with:
53+
version: "2.11"
54+
```
55+
56+
> **Quote the version.** In YAML, `version: 2.10` is parsed as the number `2.1`, which
57+
> is not what you mean. Always quote a major or minor pin: `version: "2"`, `version: "2.10"`.
58+
3159
To explicitly pin to the newest published release at runtime, pass `latest`:
3260

3361
```yaml
3462
steps:
3563
- name: setup-kosli-cli
36-
uses: kosli-dev/setup-cli-action@v3
64+
uses: kosli-dev/setup-cli-action@v5
3765
with:
3866
version: latest
3967
```
@@ -42,12 +70,22 @@ steps:
4270

4371
The action supports the following inputs:
4472

45-
- `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`.
46-
- `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.
73+
- `version`: The version of `kosli` to install. Accepts:
74+
- a full semver, e.g. `2.11.43`, installed as-is;
75+
- a major pin, e.g. `"2"`, which resolves to the newest stable `2.x` release;
76+
- a major.minor pin, e.g. `"2.11"`, which resolves to the newest stable `2.11.z` release;
77+
- the alias `latest`, which resolves to the newest stable release of `kosli-dev/cli`.
78+
79+
Major and minor pins resolve at runtime and never select a pre-release or a higher major.
80+
Quote partial versions (see the note above). Defaults to `latest`.
81+
- `github-token`: Token used to authenticate the GitHub API calls that resolve `latest` or a
82+
major/minor pin. Defaults to `${{ github.token }}`; normally you do not need to set this.
4783

4884
## Outputs
4985

50-
- `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`.
86+
- `version`: The resolved `kosli` CLI version that was installed. When `version` is `latest` or a
87+
major/minor pin, this contains the concrete semver that was selected (e.g. `2.12.0`) and can be
88+
referenced by later steps via `steps.<id>.outputs.version`.
5189

5290
## Example job
5391
See [Kosli CLI documentation](https://docs.kosli.com/)
@@ -74,7 +112,7 @@ jobs:
74112
...
75113
76114
- name: Setup kosli
77-
uses: kosli-dev/setup-cli-action@v3
115+
uses: kosli-dev/setup-cli-action@v5
78116
79117
- name: Attest ECR image provenance
80118
run:

action.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ name: setup-kosli-cli
22
description: Install the Kosli CLI on Github Actions runners
33
inputs:
44
version:
5-
description: Version of Kosli CLI. Use `latest` to install the newest release from GitHub.
5+
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.
66
required: false
77
default: latest
88
github-token:
9-
description: Token used to authenticate GitHub API calls when resolving `latest`.
9+
description: Token used to authenticate GitHub API calls when resolving `latest` or a major/minor pin.
1010
required: false
1111
default: ${{ github.token }}
1212
outputs:

src/download.js

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,110 @@ export function getDownloadUrl({ version, platform, arch }) {
2828
return `https://github.com/kosli-dev/cli/releases/download/v${version}/${filename}.${extension}`;
2929
}
3030

31+
// Classify the `version` input:
32+
// "latest" -> { kind: "latest" } resolve newest stable release
33+
// "2" / "v2" -> { kind: "partial", major: 2 } newest stable 2.x
34+
// "2.11" / "v2.11" -> { kind: "partial", major: 2, minor: 11 } newest stable 2.11.z
35+
// "2.11.43" -> { kind: "exact" } used verbatim, no API call
36+
// anything else -> { kind: "literal" } used verbatim (e.g. "Latest")
37+
export function classifyVersion(version) {
38+
if (version === "latest") {
39+
return { kind: "latest" };
40+
}
41+
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version);
42+
if (!match) {
43+
return { kind: "literal" };
44+
}
45+
const [, major, minor, patch] = match;
46+
if (patch !== undefined) {
47+
return { kind: "exact" };
48+
}
49+
return {
50+
kind: "partial",
51+
major: Number(major),
52+
minor: minor === undefined ? undefined : Number(minor)
53+
};
54+
}
55+
56+
function compareSemver(a, b) {
57+
for (let i = 0; i < 3; i++) {
58+
if (a[i] !== b[i]) {
59+
return a[i] - b[i];
60+
}
61+
}
62+
return 0;
63+
}
64+
65+
// Highest stable "X.Y.Z" within the given major (and optional minor), or null if
66+
// none match. Drafts, pre-releases and non-semver tags are ignored. Comparison is
67+
// numeric so 2.27.3 ranks above 2.9.0.
68+
function highestStableRelease(releases, major, minor) {
69+
let best = null;
70+
for (const release of releases) {
71+
if (release.draft || release.prerelease) {
72+
continue;
73+
}
74+
const parsed = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(release.tag_name || "");
75+
if (!parsed) {
76+
continue;
77+
}
78+
const candidate = [Number(parsed[1]), Number(parsed[2]), Number(parsed[3])];
79+
if (candidate[0] !== major) {
80+
continue;
81+
}
82+
if (minor !== undefined && candidate[1] !== minor) {
83+
continue;
84+
}
85+
if (best === null || compareSemver(candidate, best) > 0) {
86+
best = candidate;
87+
}
88+
}
89+
return best === null ? null : best.join(".");
90+
}
91+
3192
export async function resolveVersion(version, token, octokit) {
32-
if (version !== "latest") {
93+
const spec = classifyVersion(version);
94+
95+
// A full semver or any literal tag is used exactly as given, with no API call.
96+
if (spec.kind === "exact" || spec.kind === "literal") {
3397
return version;
3498
}
99+
35100
const client = octokit || github.getOctokit(token);
36-
let release;
101+
102+
if (spec.kind === "latest") {
103+
let release;
104+
try {
105+
release = await client.rest.repos.getLatestRelease({
106+
owner: "kosli-dev",
107+
repo: "cli"
108+
});
109+
} catch (e) {
110+
throw new Error(`failed to resolve latest Kosli CLI version from GitHub: ${e.message}`);
111+
}
112+
const tag = release.data.tag_name;
113+
return tag.startsWith("v") ? tag.slice(1) : tag;
114+
}
115+
116+
// spec.kind === "partial": newest stable release within the requested major
117+
// (and optional minor). Never selects a pre-release or a higher major.
118+
let releases;
37119
try {
38-
release = await client.rest.repos.getLatestRelease({
120+
releases = await client.paginate(client.rest.repos.listReleases, {
39121
owner: "kosli-dev",
40-
repo: "cli"
122+
repo: "cli",
123+
per_page: 100
41124
});
42125
} catch (e) {
43-
throw new Error(`failed to resolve latest Kosli CLI version from GitHub: ${e.message}`);
126+
throw new Error(`failed to resolve Kosli CLI version "${version}" from GitHub: ${e.message}`);
127+
}
128+
129+
const resolved = highestStableRelease(releases, spec.major, spec.minor);
130+
if (!resolved) {
131+
const target = spec.minor === undefined ? `${spec.major}.x` : `${spec.major}.${spec.minor}.x`;
132+
throw new Error(
133+
`no stable kosli-dev/cli release found matching version "${version}" (looked for ${target})`
134+
);
44135
}
45-
const tag = release.data.tag_name;
46-
return tag.startsWith("v") ? tag.slice(1) : tag;
136+
return resolved;
47137
}

src/index.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,10 @@ async function setup() {
1111
const platform = os.platform();
1212
const arch = os.arch();
1313

14-
const resolvedVersion = version === "latest"
15-
? await withRetries(
16-
() => resolveVersion(version, token),
17-
{ onRetry: logRetry("resolving latest version") }
18-
)
19-
: version;
14+
const resolvedVersion = await withRetries(
15+
() => resolveVersion(version, token),
16+
{ onRetry: logRetry(`resolving Kosli CLI version "${version}"`) }
17+
);
2018

2119
let pathToCLI = tc.find("kosli", resolvedVersion);
2220
if (!pathToCLI) {

test/download.test.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,91 @@ test("resolveVersion treats 'Latest' (mixed case) as a literal tag, not an alias
8181
const result = await resolveVersion("Latest", "token-unused");
8282
t.is(result, "Latest");
8383
});
84+
85+
// --- major / minor version pinning ---
86+
87+
function fakeReleasesOctokit(releases) {
88+
return {
89+
// Real octokit.paginate(endpoint, params) walks every page; the fake just
90+
// returns the canned list regardless of the endpoint passed in.
91+
paginate: async () => releases,
92+
rest: {
93+
repos: {
94+
listReleases: () => ({ data: releases })
95+
}
96+
}
97+
};
98+
}
99+
100+
const releaseFixture = [
101+
{ tag_name: "v10.0.0", draft: false, prerelease: false },
102+
{ tag_name: "v3.0.0", draft: false, prerelease: false },
103+
{ tag_name: "v2.28.0-rc.1", draft: false, prerelease: true },
104+
{ tag_name: "v2.30.0", draft: true, prerelease: false },
105+
{ tag_name: "v2.27.0", draft: false, prerelease: false },
106+
{ tag_name: "v2.27.3", draft: false, prerelease: false },
107+
{ tag_name: "v2.26.5", draft: false, prerelease: false },
108+
{ tag_name: "v2.9.0", draft: false, prerelease: false },
109+
{ tag_name: "v1.40.0", draft: false, prerelease: false },
110+
{ tag_name: "nightly", draft: false, prerelease: false }
111+
];
112+
113+
test("resolveVersion resolves a bare major to the newest stable release in that major", async t => {
114+
const result = await resolveVersion("2", "", fakeReleasesOctokit(releaseFixture));
115+
t.is(result, "2.27.3");
116+
});
117+
118+
test("resolveVersion accepts a leading v on a major pin", async t => {
119+
const result = await resolveVersion("v2", "", fakeReleasesOctokit(releaseFixture));
120+
t.is(result, "2.27.3");
121+
});
122+
123+
test("resolveVersion never resolves a major pin to a higher major", async t => {
124+
// The fixture has higher majors (v3.0.0 and v10.0.0); a "2" pin must stay on
125+
// major 2, never the highest available overall.
126+
const result = await resolveVersion("2", "", fakeReleasesOctokit(releaseFixture));
127+
t.is(result.split(".")[0], "2");
128+
});
129+
130+
test("resolveVersion excludes pre-releases and drafts from a major pin", async t => {
131+
// 2.28.0-rc.1 (prerelease) and 2.30.0 (draft) must be ignored, so 2.27.3 wins.
132+
const result = await resolveVersion("2", "", fakeReleasesOctokit(releaseFixture));
133+
t.is(result, "2.27.3");
134+
});
135+
136+
test("resolveVersion orders a major pin numerically, not lexically", async t => {
137+
// A lexical sort would rank "2.9.0" above "2.27.3"; numeric ordering must not.
138+
const octokit = fakeReleasesOctokit([
139+
{ tag_name: "v2.9.0", draft: false, prerelease: false },
140+
{ tag_name: "v2.27.3", draft: false, prerelease: false }
141+
]);
142+
t.is(await resolveVersion("2", "", octokit), "2.27.3");
143+
});
144+
145+
test("resolveVersion resolves a major.minor pin to the newest patch in that line", async t => {
146+
const result = await resolveVersion("2.27", "", fakeReleasesOctokit(releaseFixture));
147+
t.is(result, "2.27.3");
148+
});
149+
150+
test("resolveVersion resolves a major.minor pin independently of other minors", async t => {
151+
const result = await resolveVersion("2.26", "", fakeReleasesOctokit(releaseFixture));
152+
t.is(result, "2.26.5");
153+
});
154+
155+
test("resolveVersion throws a clear error when no stable release matches a partial", async t => {
156+
await t.throwsAsync(resolveVersion("4", "", fakeReleasesOctokit(releaseFixture)), {
157+
message: /no stable kosli-dev\/cli release found matching version "4".*4\.x/
158+
});
159+
});
160+
161+
test("resolveVersion surfaces a descriptive error when listing releases fails", async t => {
162+
const octokit = {
163+
paginate: async () => {
164+
throw new Error("HTTP 403 rate limit exceeded");
165+
},
166+
rest: { repos: { listReleases: () => ({ data: [] }) } }
167+
};
168+
await t.throwsAsync(resolveVersion("2", "", octokit), {
169+
message: /failed to resolve Kosli CLI version "2".*rate limit/
170+
});
171+
});

0 commit comments

Comments
 (0)