Skip to content

Commit d28998a

Browse files
committed
feat!: support installing multiple binaries
Please note the `repo` input has been renamed to `targets` to more clearly describe the input's behavior in light of this new feature. To install multiple binaries, pass a whitespace-separated list: ```yaml - name: Install flux-capacitor uses: EricCrosson/install-github-release-binary@v1 with: targets: | EricCrosson/flux-capacitor@v1 EricCrosson/steam-locomotive@v7.5.3 ``` Why the name `targets` and not `target`? Or maybe an alias from `target` to `targets`? If we accept multiple inputs, one of which has to be present, the simple input metadata can no longer describe this contract. Choose `targets` over `target` because a list of one is still a list! BREAKING CHANGE: rename `repo` to `targets`
1 parent 68fe85a commit d28998a

6 files changed

Lines changed: 215 additions & 45 deletions

File tree

README.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,28 @@ Use this action in a step:
3535
- name: Install flux-capacitor
3636
uses: EricCrosson/install-github-release-binary@v1
3737
with:
38-
repo: EricCrosson/flux-capacitor@v1
38+
targets: EricCrosson/flux-capacitor@v1
39+
```
40+
41+
Install multiple binaries:
42+
43+
```yaml
44+
- name: Install flux-capacitor
45+
uses: EricCrosson/install-github-release-binary@v1
46+
with:
47+
targets: |
48+
EricCrosson/flux-capacitor@v1
49+
EricCrosson/steam-locomotive@v7.5.3
3950
```
4051
4152
## Inputs
4253
43-
| Input Parameter | Required | Description |
44-
| :-------------: | :------: | ------------------------------------------------------------------------------------ |
45-
| repo | true | Target repository slug and tag. [Details](#repo) |
46-
| token | false | GitHub token for REST requests. Defaults to `${{ github.token }}`. [Details](#token) |
54+
| Input Parameter | Required | Description |
55+
| :-------------: | :------: | --------------------------------------------------------------------------------------------------------------- |
56+
| targets | true | Whitespace separated list of target GitHub Releases in format `{owner}/{repository}@{tag}`. [Details](#targets) |
57+
| token | false | GitHub token for REST requests. Defaults to `${{ github.token }}`. [Details](#token) |
4758

48-
#### repo
59+
#### targets
4960

5061
Target repository slug and tag.
5162

action.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ branding:
77
color: green
88

99
inputs:
10-
repo:
11-
description: Target GitHub repository slug and tag
10+
targets:
11+
description: Whitespace separated list of target GitHub Releases in format {owner}/{repository}@{tag}
1212
required: true
1313
token:
1414
description: GitHub access token or a Personal Access Token with 'repo' scope

src/either.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
export type Either<A> =
2-
| { tag: "error"; errors: string[] }
3-
| { tag: "ok"; value: A };
1+
export type Err = { tag: "error"; errors: string[] };
2+
export type Ok<A> = { tag: "ok"; value: A };
3+
export type Either<A> = Ok<A> | Err;
44

55
export function error<A>(errors: string[]): Either<A> {
66
return { tag: "error", errors };
@@ -10,6 +10,14 @@ export function ok<A>(value: A): Either<A> {
1010
return { tag: "ok", value };
1111
}
1212

13+
export function isErr<A>(value: Either<A>): value is Err {
14+
return value.tag === "error";
15+
}
16+
17+
export function isOk<A>(value: Either<A>): value is Ok<A> {
18+
return value.tag === "ok";
19+
}
20+
1321
export function unwrap<A>(either: Either<A>): A {
1422
if (either.tag === "error") {
1523
throw new Error(

src/index.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { getErrors, unwrap } from "./either";
1010
import { getOctokit, Octokit } from "./octokit";
1111
import {
1212
parseEnvironmentVariable,
13-
parseTargetRelease,
13+
parseTargetReleases,
1414
parseToken,
1515
TargetRelease,
1616
} from "./parse";
@@ -39,7 +39,7 @@ function getDestinationDirectory(
3939

4040
async function installGitHubReleaseBinary(
4141
octokit: Octokit,
42-
targetRelease: TargetRelease,
42+
targetReleases: TargetRelease,
4343
storageDirectory: string,
4444
enableCache: boolean,
4545
token: string
@@ -48,18 +48,18 @@ async function installGitHubReleaseBinary(
4848

4949
const releaseTag = await findExactSemanticVersionTag(
5050
octokit,
51-
targetRelease.slug,
52-
targetRelease.tag
51+
targetReleases.slug,
52+
targetReleases.tag
5353
);
5454

5555
const destinationDirectory = getDestinationDirectory(
5656
storageDirectory,
57-
targetRelease.slug,
57+
targetReleases.slug,
5858
releaseTag,
5959
platform(),
6060
arch()
6161
);
62-
const destinationBasename = targetRelease.slug.repository;
62+
const destinationBasename = targetReleases.slug.repository;
6363
const destinationFilename = path.join(
6464
destinationDirectory,
6565
destinationBasename
@@ -70,8 +70,8 @@ async function installGitHubReleaseBinary(
7070
// so upstream updates are always pulled in.
7171
const cachePaths = [destinationFilename];
7272
const cacheKey = [
73-
targetRelease.slug.owner.toLowerCase(),
74-
targetRelease.slug.repository.toLowerCase(),
73+
targetReleases.slug.owner.toLowerCase(),
74+
targetReleases.slug.repository.toLowerCase(),
7575
releaseTag,
7676
targetTriple,
7777
].join("-");
@@ -84,7 +84,7 @@ async function installGitHubReleaseBinary(
8484
if (restoreCache === undefined) {
8585
const releaseAsset = await fetchReleaseAssetMetadataFromTag(
8686
octokit,
87-
targetRelease.slug,
87+
targetReleases.slug,
8888
releaseTag,
8989
targetTriple
9090
);
@@ -112,10 +112,10 @@ async function main(): Promise<void> {
112112
const maybeToken = parseToken(
113113
process.env["GITHUB_TOKEN"] || core.getInput("token")
114114
);
115-
const maybeTargetRelease = parseTargetRelease(core.getInput("repo"));
115+
const maybeTargetReleases = parseTargetReleases(core.getInput("targets"));
116116
const maybeHomeDirectory = parseEnvironmentVariable("HOME");
117117

118-
const errors = [maybeToken, maybeTargetRelease, maybeHomeDirectory].flatMap(
118+
const errors = [maybeToken, maybeTargetReleases, maybeHomeDirectory].flatMap(
119119
getErrors
120120
);
121121
if (errors.length > 0) {
@@ -124,7 +124,7 @@ async function main(): Promise<void> {
124124
}
125125

126126
const token = unwrap(maybeToken);
127-
const targetRelease = unwrap(maybeTargetRelease);
127+
const targetReleases = unwrap(maybeTargetReleases);
128128
const homeDirectory = unwrap(maybeHomeDirectory);
129129
const enableCache = core.getInput("cache") === "true";
130130

@@ -135,12 +135,16 @@ async function main(): Promise<void> {
135135
);
136136
const octokit = getOctokit(token);
137137

138-
await installGitHubReleaseBinary(
139-
octokit,
140-
targetRelease,
141-
storageDirectory,
142-
enableCache,
143-
token
138+
await Promise.all(
139+
targetReleases.map((targetRelease) =>
140+
installGitHubReleaseBinary(
141+
octokit,
142+
targetRelease,
143+
storageDirectory,
144+
enableCache,
145+
token
146+
)
147+
)
144148
);
145149
}
146150

src/parse.ts

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1-
import { Either, error, ok } from "./either";
1+
import { Either, error, isOk, ok } from "./either";
22
import type { RepositorySlug, SemanticVersion } from "./types";
33

4+
export function parseEnvironmentVariable(envVarName: string): Either<string> {
5+
const value = process.env[envVarName];
6+
if (value === undefined) {
7+
return error([
8+
`Expected environment variable '${envVarName}' to be defined`,
9+
]);
10+
}
11+
if (value.length === 0) {
12+
return error([
13+
`Expected environment variable '${envVarName}' to be non-empty`,
14+
]);
15+
}
16+
return ok(value);
17+
}
18+
419
export function parseToken(value: string): Either<string> {
520
if (value.length === 0) {
621
return error(["input.github expected to be non-empty"]);
@@ -13,10 +28,7 @@ export type TargetRelease = {
1328
tag: SemanticVersion;
1429
};
1530

16-
export function parseTargetRelease(value: string): Either<TargetRelease> {
17-
if (value.length === 0) {
18-
return error(["input.repo not defined"]);
19-
}
31+
function parseTargetRelease(value: string): Either<TargetRelease> {
2032
const errors: string[] = [];
2133
const repo_regex = /^(\S+)\/(\S+)$/;
2234
if (repo_regex.test(value) === null) {
@@ -54,17 +66,29 @@ export function parseTargetRelease(value: string): Either<TargetRelease> {
5466
return ok(target);
5567
}
5668

57-
export function parseEnvironmentVariable(envVarName: string): Either<string> {
58-
const value = process.env[envVarName];
59-
if (value === undefined) {
60-
return error([
61-
`Expected environment variable '${envVarName}' to be defined`,
62-
]);
63-
}
69+
export function parseTargetReleases(value: string): Either<TargetRelease[]> {
6470
if (value.length === 0) {
65-
return error([
66-
`Expected environment variable '${envVarName}' to be non-empty`,
67-
]);
71+
return error(["input.targets not defined"]);
6872
}
69-
return ok(value);
73+
const tokens = value.trim().split(/\s+/);
74+
75+
const results: TargetRelease[] = [];
76+
const errors: string[] = [];
77+
78+
for (const token of tokens) {
79+
const release = parseTargetRelease(token);
80+
if (isOk(release)) {
81+
results.push(release.value);
82+
} else {
83+
const errorMessage =
84+
`Encountered errors parsing target ${token}:` +
85+
release.errors.map((error) => `\n - ${error}`).join("\n");
86+
errors.push(errorMessage);
87+
}
88+
}
89+
90+
if (errors.length > 0) {
91+
return error(errors);
92+
}
93+
return ok(results);
7094
}

test/test-parse-target-release.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import { parseTargetReleases, TargetRelease } from "../src/parse";
5+
import { Either, error, isErr, isOk, ok } from "../src/either";
6+
import type { SemanticVersion } from "../src/types";
7+
8+
function err<A>(): Either<A> {
9+
return error([""]);
10+
}
11+
12+
function check(
13+
input: string,
14+
expectedOutput: Either<readonly TargetRelease[]>
15+
) {
16+
// wrap in a thunk so we can pass it directly to `test`
17+
const actualOutput = parseTargetReleases(input);
18+
return function () {
19+
if (isOk(expectedOutput)) {
20+
assert.deepEqual(actualOutput, expectedOutput);
21+
} else {
22+
// I don't care too much right now what the error is
23+
assert(isErr(actualOutput));
24+
}
25+
};
26+
}
27+
28+
test("should not parse an empty string", check("", err()));
29+
30+
test("should not parse a slug without a version", check("foo/bar", err()));
31+
32+
test(
33+
"should not parse a slug without a version starting with v",
34+
check("foo/bar@1", err())
35+
);
36+
37+
test(
38+
"should parse a slug and version starting with v",
39+
check(
40+
"foo/bar@v1",
41+
ok([
42+
{
43+
slug: {
44+
owner: "foo",
45+
repository: "bar",
46+
},
47+
tag: "v1" as SemanticVersion,
48+
},
49+
])
50+
)
51+
);
52+
53+
test(
54+
"should parse multiple targets separated with whitespace",
55+
check(
56+
"foo/bar@v1 qux/baz@v2.3.4",
57+
ok([
58+
{
59+
slug: {
60+
owner: "foo",
61+
repository: "bar",
62+
},
63+
tag: "v1" as SemanticVersion,
64+
},
65+
{
66+
slug: {
67+
owner: "qux",
68+
repository: "baz",
69+
},
70+
tag: "v2.3.4" as SemanticVersion,
71+
},
72+
])
73+
)
74+
);
75+
76+
test(
77+
"should parse multiple targets separated with whitespace with leading whitespace",
78+
check(
79+
`
80+
foo/bar@v1 qux/baz@v2.3.4`,
81+
ok([
82+
{
83+
slug: {
84+
owner: "foo",
85+
repository: "bar",
86+
},
87+
tag: "v1" as SemanticVersion,
88+
},
89+
{
90+
slug: {
91+
owner: "qux",
92+
repository: "baz",
93+
},
94+
tag: "v2.3.4" as SemanticVersion,
95+
},
96+
])
97+
)
98+
);
99+
100+
test(
101+
"should parse multiple targets separated with whitespace with trailing whitespace",
102+
check(
103+
`foo/bar@v1
104+
qux/baz@v2.3.4
105+
`,
106+
ok([
107+
{
108+
slug: {
109+
owner: "foo",
110+
repository: "bar",
111+
},
112+
tag: "v1" as SemanticVersion,
113+
},
114+
{
115+
slug: {
116+
owner: "qux",
117+
repository: "baz",
118+
},
119+
tag: "v2.3.4" as SemanticVersion,
120+
},
121+
])
122+
)
123+
);

0 commit comments

Comments
 (0)