Skip to content

Commit e9c3331

Browse files
committed
feat: support pinning target by sha1 hash
Closes #26 Accept a new format to specify a target: ``` {owner}/{repository}@{sha1-hash} ``` Note that this commit must still be associated with a GitHub release with an exact semantic version, because the release is the mechanism of hosting pre-compiled binaries. For example: ```yaml - uses: EricCrosson/install-github-release-binary@v2 with: targets: EricCrosson/hoverboard@7382f9a3ce14be1fd8b3656d262fc2c720c8f51c ```
1 parent bf82528 commit e9c3331

9 files changed

Lines changed: 236 additions & 61 deletions

README.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,26 +47,33 @@ Install multiple binaries:
4747
targets: |
4848
EricCrosson/flux-capacitor@v1
4949
EricCrosson/steam-locomotive@v7.5.3
50+
EricCrosson/hoverboard@7382f9a3ce14be1fd8b3656d262fc2c720c8f51c
5051
```
5152
5253
## Inputs
5354
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) |
55+
| Input Parameter | Required | Description |
56+
| :-------------: | :------: | ------------------------------------------------------------------------------------------------------------------- |
57+
| targets | true | Whitespace separated list of target GitHub Releases in format `{owner}/{repository}@{version}`. [Details](#targets) |
58+
| token | false | GitHub token for REST requests. Defaults to `${{ github.token }}`. [Details](#token) |
5859

5960
#### targets
6061

61-
Target repository slug and tag: `{owner}/{repository}@v{semantic-version}`.
62-
Example: `EricCrosson/flux-capacitor@v1`.
62+
Specify a whitespace-separated list of targets.
63+
64+
Each target is specified by repo slug and a [semantic version number] or sha1 hash.
65+
66+
Accepted formats:
67+
68+
- `{owner}/{repository}@v{semantic-version}`
69+
- `{owner}/{repository}@{sha1-hash}`
6370

64-
Tags are of the format `v{semantic-version}`, where `{semantic-version}` is a [semantic version number].
6571
Examples:
6672

67-
- `v1`
68-
- `v1.2`
69-
- `v1.2.3`
73+
- `EricCrosson/flux-capacitor@v1`
74+
- `EricCrosson/flux-capacitor@v1.2`
75+
- `EricCrosson/flux-capacitor@v1.2.3`
76+
- `EricCrosson/flux-capacitor@aaebf5c572b64d17710f936905c1b35e1f58acc8`
7077

7178
[semantic version number]: https://semver.org/
7279

src/fetch.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import {
66
ExactSemanticVersion,
77
RepositorySlug,
88
SemanticVersion,
9-
Sha,
9+
Sha1Hash,
1010
TargetTriple,
11+
isSha1Hash,
1112
} from "./types";
1213

1314
type Commit = {
14-
sha: Sha;
15+
sha: Sha1Hash;
1516
};
1617

1718
// This type is only exported for testing.
@@ -22,6 +23,19 @@ export type Tag = {
2223

2324
export type TagsResponse = ReadonlyArray<Tag>;
2425

26+
// This function is only exported for testing.
27+
export function sha1HashReducer(
28+
givenSha: Sha1Hash
29+
): (tag: Tag) => Option<ExactSemanticVersion> {
30+
return function reducer(tag: Tag): Option<ExactSemanticVersion> {
31+
const version = tag.name;
32+
if (tag.commit.sha === givenSha && isExactSemanticVersion(version)) {
33+
return some(version);
34+
}
35+
return none();
36+
};
37+
}
38+
2539
function containsExactTag(
2640
tags: readonly SemanticVersion[] | undefined
2741
): ExactSemanticVersion | undefined {
@@ -32,9 +46,11 @@ function containsExactTag(
3246
}
3347

3448
// This function is only exported for testing.
35-
export function exactSemanticVersionTagReducer(givenTag: SemanticVersion) {
36-
const versionsBySha: Record<Sha, SemanticVersion[]> = {};
37-
let givenTagSha: Option<Sha> = none();
49+
export function semanticVersionTagReducer(
50+
givenTag: SemanticVersion
51+
): (tag: Tag) => Option<ExactSemanticVersion> {
52+
const versionsBySha: Record<Sha1Hash, SemanticVersion[]> = {};
53+
let givenTagSha: Option<Sha1Hash> = none();
3854

3955
// Conditions for an exact match are -- we know both the:
4056
//
@@ -88,13 +104,15 @@ export function exactSemanticVersionTagReducer(givenTag: SemanticVersion) {
88104
export async function findExactSemanticVersionTag(
89105
octokit: Octokit,
90106
slug: RepositorySlug,
91-
givenTag: SemanticVersion
107+
target: SemanticVersion | Sha1Hash
92108
): Promise<ExactSemanticVersion> {
93-
if (isExactSemanticVersion(givenTag)) {
94-
return givenTag;
109+
if (isExactSemanticVersion(target)) {
110+
return target;
95111
}
96112

97-
const reducer = exactSemanticVersionTagReducer(givenTag);
113+
const reducer = isSha1Hash(target)
114+
? sha1HashReducer(target)
115+
: semanticVersionTagReducer(target);
98116

99117
for await (const response of octokit.paginate.iterator(
100118
octokit.rest.repos.listTags,
@@ -113,8 +131,12 @@ export async function findExactSemanticVersionTag(
113131
}
114132
}
115133

134+
const expected = isSha1Hash(target)
135+
? "a commit"
136+
: "an exact semantic version tag";
137+
116138
throw new Error(
117-
`Expected to find an exact semantic version tag matching ${givenTag} for ${slug.owner}/${slug.repository}`
139+
`Expected to find ${expected} matching ${target} for ${slug.owner}/${slug.repository}`
118140
);
119141
}
120142

src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@ import {
1212
parseEnvironmentVariable,
1313
parseTargetReleases,
1414
parseToken,
15-
TargetRelease,
1615
} from "./parse";
1716
import { getTargetTriple } from "./platform";
1817
import {
1918
fetchReleaseAssetMetadataFromTag,
2019
findExactSemanticVersionTag,
2120
} from "./fetch";
22-
import type { ExactSemanticVersion, RepositorySlug } from "./types";
21+
import type {
22+
ExactSemanticVersion,
23+
RepositorySlug,
24+
TargetRelease,
25+
} from "./types";
2326

2427
function getDestinationDirectory(
2528
storageDirectory: string,

src/parse.ts

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { Either, error, isOk, ok } from "./either";
2-
import type { RepositorySlug, SemanticVersion } from "./types";
2+
import type { SemanticVersion, Sha1Hash, TargetRelease } from "./types";
3+
4+
const regexes = {
5+
owner: /\S+/,
6+
repository: /\S+/,
7+
majorSemanticVersion: /v(0|[1-9]\d*)/,
8+
majorMinorSemanticVersion: /v(0|[1-9]\d*)\.(0|[1-9]\d*)/,
9+
exactSemanticVersion:
10+
/v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/,
11+
sha1Hash: /[0-9a-f]{40}/,
12+
};
313

414
export function parseEnvironmentVariable(envVarName: string): Either<string> {
515
const value = process.env[envVarName];
@@ -23,49 +33,45 @@ export function parseToken(value: string): Either<string> {
2333
return ok(value);
2434
}
2535

26-
export type TargetRelease = {
27-
slug: RepositorySlug;
28-
tag: SemanticVersion;
29-
};
30-
31-
function parseTargetRelease(value: string): Either<TargetRelease> {
32-
const errors: string[] = [];
33-
const repo_regex = /^(\S+)\/(\S+)$/;
34-
if (repo_regex.test(value) === null) {
35-
errors.push(
36-
"input.repo does not describe a GitHub repository -- expected {owner}/{repository}@{tag} format (example: EricCrosson/git-disjoint@v2)"
37-
);
38-
}
39-
const tag_regex = /^(\S+)@v(\S+)$/;
40-
if (tag_regex.test(value) === null) {
41-
errors.push(
42-
"input.repo does not describe a tag version -- expected {owner}/{repository}@{tag} format (example: EricCrosson/git-disjoint@v2)"
43-
);
44-
}
45-
if (errors.length > 0) {
46-
return error(errors);
47-
}
48-
49-
const regex = /^(\S+)\/(\S+)@(v\S+)$/;
36+
function parseTargetReleaseVersion(value: string): Either<TargetRelease> {
37+
const {
38+
owner,
39+
repository,
40+
majorSemanticVersion,
41+
majorMinorSemanticVersion,
42+
exactSemanticVersion,
43+
sha1Hash,
44+
} = regexes;
45+
const regex = new RegExp(
46+
`^(${owner.source})/(${repository.source})@((${sha1Hash.source})|${majorSemanticVersion.source}|${majorMinorSemanticVersion.source}|${exactSemanticVersion.source})$`
47+
);
5048
const match = value.match(regex);
51-
if (match === null || match.length !== 4) {
52-
return error([
53-
"input.repo invalid -- expected {owner}/{repository}@{tag} format (example: EricCrosson/git-disjoint@v2)",
54-
]);
49+
if (match === null) {
50+
// This error message is never used
51+
return error(["not a valid target release"]);
5552
}
56-
5753
const target: TargetRelease = {
5854
slug: {
5955
owner: match[1] as string,
6056
repository: match[2] as string,
6157
},
62-
// NOTE: we're not really parsing the content of this string,
63-
// so this is an unlawful type assertion
64-
tag: match[3] as SemanticVersion,
58+
tag: match[3] as SemanticVersion | Sha1Hash,
6559
};
6660
return ok(target);
6761
}
6862

63+
function parseTargetRelease(value: string): Either<TargetRelease> {
64+
const maybeTargetRelease = parseTargetReleaseVersion(value);
65+
66+
if (isOk(maybeTargetRelease)) {
67+
return ok(maybeTargetRelease.value);
68+
}
69+
70+
return error([
71+
`input.targetes invalid -- '${value}' does not match expected format '{owner}/{repository}@{tag}' (example: EricCrosson/git-disjoint@v2)`,
72+
]);
73+
}
74+
6975
export function parseTargetReleases(value: string): Either<TargetRelease[]> {
7076
if (value.length === 0) {
7177
return error(["input.targets not defined"]);

src/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,21 @@ export function isExactSemanticVersion(
2121
return regex.test(value);
2222
}
2323

24-
export type Sha = string & { readonly __tag: unique symbol };
24+
export type Sha1Hash = string & { readonly __tag: unique symbol };
25+
26+
export function isSha1Hash(value: string): value is Sha1Hash {
27+
const regex = /^[0-9a-f]{40}$/;
28+
return regex.test(value);
29+
}
2530

2631
export type RepositorySlug = {
2732
owner: string;
2833
repository: string;
2934
};
3035

3136
export type TargetTriple = string & { readonly __tag: unique symbol };
37+
38+
export type TargetRelease = {
39+
slug: RepositorySlug;
40+
tag: SemanticVersion | Sha1Hash;
41+
};

test/test-find-exact-semantic-version-tag.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import test from "node:test";
22
import assert from "node:assert/strict";
33

44
import type { ExactSemanticVersion, SemanticVersion } from "../src/types";
5-
import { exactSemanticVersionTagReducer, Tag } from "../src/fetch";
5+
import { semanticVersionTagReducer, Tag } from "../src/fetch";
66
import { isNone, isSome, none, Option, some } from "../src/option";
77

88
import noReleaseCandidates from "./no-release-candidate-workflow.json";
@@ -13,9 +13,11 @@ function check(
1313
testData: readonly Tag[],
1414
expectedOutput: Option<ExactSemanticVersion>
1515
) {
16-
const reducer = exactSemanticVersionTagReducer(givenTag);
16+
const reducer = semanticVersionTagReducer(givenTag);
17+
// Take the first 'some'
1718
const actualOutput = testData.reduce<Option<ExactSemanticVersion>>(
18-
(_previousResult, tag) => reducer(tag),
19+
(previousResult, tag) =>
20+
isNone(previousResult) ? reducer(tag) : previousResult,
1921
none()
2022
);
2123

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import type { ExactSemanticVersion, SemanticVersion } from "../src/types";
5+
import { semanticVersionTagReducer, Tag } from "../src/fetch";
6+
import { isNone, isSome, none, Option, some } from "../src/option";
7+
8+
import noReleaseCandidates from "./no-release-candidate-workflow.json";
9+
import withReleaseCandidates from "./beta-workflow.json";
10+
11+
function check(
12+
givenTag: SemanticVersion,
13+
testData: readonly Tag[],
14+
expectedOutput: Option<ExactSemanticVersion>
15+
) {
16+
const reducer = semanticVersionTagReducer(givenTag);
17+
const actualOutput = testData.reduce<Option<ExactSemanticVersion>>(
18+
(_previousResult, tag) => reducer(tag),
19+
none()
20+
);
21+
22+
// wrap in a thunk so we can pass it directly to `test`
23+
return function () {
24+
if (isSome(expectedOutput)) {
25+
assert.deepEqual(actualOutput, expectedOutput);
26+
} else {
27+
// I don't care too much right now what the error is
28+
assert(isNone(actualOutput));
29+
}
30+
};
31+
}
32+
33+
test(
34+
"should resolve an exact semantic tag when the upstream project does not use release candidates",
35+
check(
36+
"v1" as SemanticVersion,
37+
noReleaseCandidates as unknown as Tag[],
38+
some("v1.0.8" as ExactSemanticVersion)
39+
)
40+
);
41+
42+
test(
43+
"should resolve an exact semantic tag when the upstream project use release candidates",
44+
check(
45+
"v1" as SemanticVersion,
46+
withReleaseCandidates as unknown as Tag[],
47+
some("v1.1.0-beta.1" as ExactSemanticVersion)
48+
)
49+
);

0 commit comments

Comments
 (0)