Skip to content

Commit c75db08

Browse files
Introduce version package that incapsulates all the logic for getting CLI version
1 parent c4ef89b commit c75db08

File tree

10 files changed

+305
-74
lines changed

10 files changed

+305
-74
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,15 @@
3838
"dependencies": {
3939
"@actions/core": "^1.11.1",
4040
"@actions/tool-cache": "^2.0.2",
41-
"cheerio": "^1.1.0"
41+
"cheerio": "^1.1.0",
42+
"semver": "^7.7.2"
4243
},
4344
"devDependencies": {
4445
"@1password/eslint-config": "^8.1.0",
4546
"@1password/prettier-config": "^1.2.0",
4647
"@types/jest": "^30.0.0",
4748
"@types/node": "^24.0.14",
49+
"@types/semver": "^7.7.0",
4850
"@vercel/ncc": "^0.38.3",
4951
"husky": "^9.1.7",
5052
"jest": "^30.0.4",

src/index.ts

Lines changed: 6 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,28 @@
1-
import * as https from "https";
21
import * as core from "@actions/core";
3-
import * as cheerio from "cheerio";
42
import {
53
type Installer,
64
RunnerOS,
75
LinuxInstaller,
86
MacOSInstaller,
97
WindowsInstaller,
108
} from "./cli-installer";
11-
12-
export enum VersionType {
13-
Latest = "latest",
14-
LatestBeta = "latest-beta",
15-
}
16-
17-
// Returns `true` if the versionType is one of the defined in VersionType enum, otherwise `false`.
18-
const isValidVersionType = (versionType: string): boolean =>
19-
Object.values(VersionType).some((v) => v === versionType);
20-
21-
// Loads the HTML content from the 1Password CLI product history page.
22-
export const loadHtml = (): Promise<string> => {
23-
return new Promise((resolve, reject) => {
24-
https
25-
.get("https://app-updates.agilebits.com/product_history/CLI2", (res) => {
26-
let data = "";
27-
res.on("data", (chunk) => (data += chunk));
28-
res.on("end", () => {
29-
core.debug("HTML loaded successfully");
30-
resolve(data)
31-
});
32-
})
33-
.on("error", (e) => {
34-
core.error(`Failed to load HTML: ${e.message}`);
35-
reject();
36-
});
37-
});
38-
};
39-
40-
// Returns the latest version of the 1Password CLI based on the specified channel.
41-
export const getLatestVersion = async (channel: VersionType): Promise<string> => {
42-
core.debug(`Getting ${channel} version`);
43-
const html = await loadHtml();
44-
const $ = cheerio.load(html);
45-
const versions: string[] = [];
46-
$("h3").each((_, el) => {
47-
const text = $(el).text().trim();
48-
const match = text.match(/^([\d.]+(?:-beta\d+)?)/);
49-
if (match) {
50-
const version = match[1];
51-
if (!version) {
52-
return
53-
}
54-
if (
55-
(channel === VersionType.Latest && !version.includes("-beta")) ||
56-
(channel === VersionType.LatestBeta && version.includes("-beta"))
57-
) {
58-
versions.push(version);
59-
}
60-
}
61-
});
62-
63-
if (versions.length === 0) {
64-
core.error(`No ${channel} versions found`);
65-
throw new Error(`No ${channel} versions found`);
66-
}
67-
68-
return versions[0]!;
69-
};
9+
import { VersionResolver } from "./version";
7010

7111
async function run(): Promise<void> {
7212
try {
73-
const versionType = core.getInput("version") as VersionType;
74-
// validate the version input. Should be one of the VersionTypes.
75-
if (!isValidVersionType(versionType)) {
76-
core.setFailed(
77-
`Invalid version input: ${versionType}. Valid options are: ${Object.values(VersionType).join(", ")}. Defaulting to 'latest'.`,
78-
);
79-
return;
80-
}
13+
const versionResolver = new VersionResolver(core.getInput("version"));
14+
await versionResolver.resolve();
8115

82-
const version = await getLatestVersion(versionType);
8316
let installer: Installer;
8417
switch (process.env.RUNNER_OS) {
8518
case RunnerOS.Linux:
86-
installer = new LinuxInstaller(version);
19+
installer = new LinuxInstaller(versionResolver.get());
8720
break;
8821
case RunnerOS.MacOS:
89-
installer = new MacOSInstaller(version);
22+
installer = new MacOSInstaller(versionResolver.get());
9023
break;
9124
case RunnerOS.Windows:
92-
installer = new WindowsInstaller(version);
25+
installer = new WindowsInstaller(versionResolver.get());
9326
break;
9427
default:
9528
core.setFailed(`Unsupported platform: ${process.env.RUNNER_OS}`);

src/version/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export enum ReleaseChannel {
2+
Stable = "latest",
3+
Beta = "latest-beta",
4+
}

src/version/helper.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { getLatestVersion } from "./helper";
2+
import * as helper from "./helper";
3+
import { ReleaseChannel } from "./constants";
4+
5+
describe("getLatestVersion", () => {
6+
afterEach(() => {
7+
jest.restoreAllMocks();
8+
});
9+
10+
it("returns latest stable version", async () => {
11+
jest.spyOn(helper, "loadHtml").mockResolvedValue(`
12+
<html><body>
13+
<article><h3> 2.31.1 </h3><span>build number and release date</span></article>
14+
<article><h3> 2.32.1 </h3><span>build number and release date</span></article>
15+
<article><h3> 2.33.2 </h3><span>build number and release date</span></article>
16+
<article><h3>2.33.1 </h3><span>build number and release date</span></article>
17+
</body></html>
18+
`);
19+
const version = await getLatestVersion(ReleaseChannel.Stable);
20+
expect(version).toBe("2.33.2");
21+
});
22+
23+
it("returns latest beta version", async () => {
24+
jest.spyOn(helper, "loadHtml").mockResolvedValue(`
25+
<html><body>
26+
<article class="beta"><h3> 1.32.0-beta.01 <span>build number and release date</span></h3></article>
27+
<article class="beta"><h3>2.32.0-beta.01 <span>build number and release date</span></h3></article>
28+
<article class="beta"><h3>3.32.0-beta.02<span>build number and release date</span></h3></article>
29+
<article class="beta"><h3>3.32.0-beta.01 <span>build number and release date</span></h3></article>
30+
</body></html>
31+
`);
32+
const version = await getLatestVersion(ReleaseChannel.Beta);
33+
expect(version).toBe("3.32.0-beta.02");
34+
});
35+
36+
it("throws error when no versions found", async () => {
37+
jest.spyOn(helper, "loadHtml").mockResolvedValue("<html></html>");
38+
await expect(getLatestVersion(ReleaseChannel.Stable)).rejects.toThrow(
39+
`No ${ReleaseChannel.Stable} versions found`,
40+
);
41+
});
42+
43+
it("throws error when HTML is invalid", async () => {
44+
jest
45+
.spyOn(helper, "loadHtml")
46+
.mockResolvedValue("<html><article></article></html>");
47+
await expect(getLatestVersion(ReleaseChannel.Stable)).rejects.toThrow(
48+
`No ${ReleaseChannel.Stable} versions found`,
49+
);
50+
});
51+
52+
it("calls loadHtml once", async () => {
53+
const spy = jest.spyOn(helper, "loadHtml").mockResolvedValue(`
54+
<article><h3>2.31.1</h3></article>
55+
`);
56+
await getLatestVersion(ReleaseChannel.Stable);
57+
expect(spy).toHaveBeenCalledTimes(1);
58+
});
59+
});

src/version/helper.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as https from "https";
2+
import * as core from "@actions/core";
3+
import * as cheerio from "cheerio";
4+
import semver from "semver";
5+
import { ReleaseChannel } from "./constants";
6+
7+
// Loads the HTML content from the 1Password CLI product history page.
8+
export const loadHtml = (): Promise<string> => {
9+
return new Promise((resolve, reject) => {
10+
https
11+
.get("https://app-updates.agilebits.com/product_history/CLI2", (res) => {
12+
let data = "";
13+
res.on("data", (chunk) => (data += chunk));
14+
res.on("end", () => {
15+
core.debug("HTML loaded successfully");
16+
resolve(data);
17+
});
18+
})
19+
.on("error", (e) => {
20+
core.error(`Failed to load HTML: ${e.message}`);
21+
reject();
22+
});
23+
});
24+
};
25+
26+
// Finds and returns an array of version strings from the HTML content based on the provided query.
27+
// Version stored in the first <h3> element of each article element.
28+
const findVersions = ($: cheerio.CheerioAPI, query: string): string[] => {
29+
const versions: string[] = [];
30+
$(query).each((_, article) => {
31+
const h3 = $(article).find("h3").first();
32+
const version = h3
33+
.contents()
34+
.filter((_, el) => el.type === "text") // select only text nodes
35+
.text()
36+
.trim();
37+
if (version) {
38+
versions.push(version);
39+
}
40+
});
41+
return versions;
42+
};
43+
44+
export const normalizeBetaForSemver = (version: string): string =>
45+
version.replace(/-beta\.0*(\d+)/, "-beta.$1");
46+
47+
// Returns the latest version of the 1Password CLI based on the specified channel.
48+
export const getLatestVersion = async (
49+
versionType: ReleaseChannel,
50+
): Promise<string> => {
51+
core.debug(`Getting ${versionType} version`);
52+
const html = await loadHtml();
53+
const $ = cheerio.load(html);
54+
const versions =
55+
versionType === ReleaseChannel.Beta
56+
? findVersions($, "article.beta")
57+
: findVersions($, "article");
58+
59+
if (versions.length === 0) {
60+
core.error(`No ${versionType} versions found`);
61+
throw new Error(`No ${versionType} versions found`);
62+
}
63+
64+
// Sort versions in descending order
65+
versions.sort((a: string, b: string): number => {
66+
const aNorm = new semver.SemVer(normalizeBetaForSemver(a));
67+
const bNorm = new semver.SemVer(normalizeBetaForSemver(b));
68+
return semver.rcompare(aNorm, bNorm);
69+
});
70+
71+
return versions[0]!;
72+
};

src/version/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { VersionResolver } from "./version-resolver";

src/version/validate.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { validateVersion } from "./validate";
2+
import { describe, it, expect } from "@jest/globals";
3+
4+
describe("validateVersion", () => {
5+
it('should not throw for "latest"', () => {
6+
expect(() => validateVersion("latest")).not.toThrow();
7+
});
8+
9+
it('should not throw for "latest-beta"', () => {
10+
expect(() => validateVersion("latest-beta")).not.toThrow();
11+
});
12+
13+
it('should not throw for valid semver version "2.18.0"', () => {
14+
expect(() => validateVersion("2.18.0")).not.toThrow();
15+
});
16+
17+
it('should throw for partial version "2"', () => {
18+
expect(() => validateVersion("2")).toThrow();
19+
});
20+
21+
it('should throw for partial version "2.1"', () => {
22+
expect(() => validateVersion("2.1")).toThrow();
23+
});
24+
25+
it('should not throw for valid beta "2.19.0-beta.01"', () => {
26+
expect(() => validateVersion("2.19.0-beta.01")).not.toThrow();
27+
});
28+
29+
it('should not throw for coerced version "v2.19.0"', () => {
30+
expect(() => validateVersion("v2.19.0")).not.toThrow();
31+
});
32+
33+
it('should throw for invalid version "latest-abc"', () => {
34+
expect(() => validateVersion("latest-abc")).toThrow();
35+
});
36+
37+
it("should throw for empty string", () => {
38+
expect(() => validateVersion("")).toThrow();
39+
});
40+
});

src/version/validate.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import semver from "semver";
2+
import { ReleaseChannel } from "./constants";
3+
import {normalizeBetaForSemver} from "./helper";
4+
5+
// Validates if the provided version type is a valid enum value or a valid semver version.
6+
export const validateVersion = (input: string): void => {
7+
if (Object.values(ReleaseChannel).includes(input as ReleaseChannel)) {
8+
return;
9+
}
10+
11+
// 1Password beta releases (aka 2.19.0-beta.01) are not semver compliant.
12+
// According to semver, it should be "2.19.0-beta.1".
13+
// That's why we need to normalize them before validating.
14+
// Accepts valid semver versions like "2.18.0" or beta-releases like "2.19.0-beta.01"
15+
// or versions with 'v' prefix like "v2.19.0"
16+
const normalized = normalizeBetaForSemver(input)
17+
const normInput = new semver.SemVer(normalized)
18+
if (semver.valid(normInput)) {
19+
return;
20+
}
21+
22+
throw new Error(`Invalid version input: ${input}`);
23+
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { VersionResolver } from "./version-resolver";
2+
import { ReleaseChannel } from "./constants";
3+
import { expect } from "@jest/globals";
4+
5+
describe("VersionResolver", () => {
6+
test("should throw error when invalid version provided", async () => {
7+
expect(() => new VersionResolver("vv")).toThrow();
8+
});
9+
10+
test("should throw error when version is empty", () => {
11+
expect(() => new VersionResolver("")).toThrow();
12+
});
13+
14+
test("should throw error for major version only", async () => {
15+
expect(() => new VersionResolver("1")).toThrow();
16+
});
17+
18+
test("should throw error for major and minor version only", async () => {
19+
expect(() => new VersionResolver("1.0")).toThrow();
20+
});
21+
22+
test("should resolve latest stable version", async () => {
23+
const versionResolver = new VersionResolver(ReleaseChannel.Stable);
24+
await versionResolver.resolve();
25+
expect(versionResolver.get()).toBeDefined();
26+
});
27+
28+
test("should resolve latest beta version", async () => {
29+
const versionResolver = new VersionResolver(ReleaseChannel.Beta);
30+
await versionResolver.resolve();
31+
expect(versionResolver.get()).toBeDefined();
32+
});
33+
34+
test("should resolve version without 'v' prefix", async () => {
35+
const versionResolver = new VersionResolver("1.0.0");
36+
await versionResolver.resolve();
37+
expect(versionResolver.get()).toBe("v1.0.0");
38+
});
39+
40+
test("should resolve version with 'v' prefix", async () => {
41+
const versionResolver = new VersionResolver("v1.0.0");
42+
await versionResolver.resolve();
43+
expect(versionResolver.get()).toBe("v1.0.0");
44+
});
45+
46+
test("should resolve beta version without 'v' prefix", async () => {
47+
const versionResolver = new VersionResolver("2.19.0-beta.01");
48+
await versionResolver.resolve();
49+
expect(versionResolver.get()).toBe("v2.19.0-beta.01");
50+
});
51+
52+
test("should resolve beta version with 'v' prefix", async () => {
53+
const versionResolver = new VersionResolver("v2.19.0-beta.01");
54+
await versionResolver.resolve();
55+
expect(versionResolver.get()).toBe("v2.19.0-beta.01");
56+
});
57+
});

0 commit comments

Comments
 (0)