Skip to content

Commit a277d43

Browse files
committed
fix: use official regular expression to detect semantic versions
Closes #22 This fixes an issue where the upstream major-version tags pointed at a release candidate. For example, tag `v1` pointed to `v1.1.0-beta.1`, which arguably shouldn't happen, but this appears to be the default behavior of semantic-release-major-tag[^1]. This commit supports this behavior by removing the assumption that an exact semantic version points to three numbers separated by two dots, and instead matches exact semantic versions using the officially suggested regular expression[^2]. [^1]: https://github.com/doteric/semantic-release-major-tag [^2]: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
1 parent 17f43a7 commit a277d43

6 files changed

Lines changed: 233 additions & 45 deletions

File tree

src/fetch.ts

Lines changed: 57 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Octokit } from "./octokit";
2-
import { isEqual, none, Option, some } from "./option";
2+
import { isEqual, isSome, none, Option, some } from "./option";
33

44
import {
55
isExactSemanticVersion,
@@ -14,7 +14,8 @@ type Commit = {
1414
sha: Sha;
1515
};
1616

17-
type Tag = {
17+
// This type is only exported for testing.
18+
export type Tag = {
1819
name: SemanticVersion;
1920
commit: Commit;
2021
};
@@ -30,6 +31,56 @@ function containsExactTag(
3031
return tags.find(isExactSemanticVersion);
3132
}
3233

34+
// 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();
38+
39+
// Conditions for an exact match are -- we know both the:
40+
//
41+
// - sha that the given tag points to
42+
// - exact version tag matching that sha
43+
//
44+
// These can be found in either order.
45+
return function reducer(tag: Tag): Option<ExactSemanticVersion> {
46+
const sha = tag.commit.sha;
47+
const version = tag.name;
48+
49+
// If we found the sha the given tag points to
50+
if (version === givenTag) {
51+
givenTagSha = some(sha);
52+
// check if we already knew the exact version tag matching that sha
53+
const maybeExactTag = containsExactTag(versionsBySha[sha]);
54+
if (maybeExactTag !== undefined) {
55+
return some(maybeExactTag);
56+
}
57+
}
58+
59+
// If we're not looking at the given tag, and we're not looking
60+
// at an exact version, this data is of no use to us.
61+
if (!isExactSemanticVersion(version)) {
62+
return none();
63+
}
64+
65+
// It is possible that we know the sha for the given tag,
66+
// we're just looking for exact version tag matching that sha.
67+
if (isEqual(givenTagSha, sha)) {
68+
return some(version);
69+
}
70+
71+
// Otherwise, record this map of sha -> exact version tag
72+
// so we can find it when we know the sha of the given tag.
73+
const associatedVersions = versionsBySha[sha];
74+
if (associatedVersions === undefined) {
75+
versionsBySha[sha] = [version];
76+
} else {
77+
associatedVersions.push(version);
78+
}
79+
80+
return none();
81+
};
82+
}
83+
3384
// Find the exact semantic version tag that this tag maps to.
3485
//
3586
// We need an exact tag because that's the only accepted input
@@ -43,15 +94,7 @@ export async function findExactSemanticVersionTag(
4394
return givenTag;
4495
}
4596

46-
const versionsBySha: Record<Sha, SemanticVersion[]> = {};
47-
let givenTagSha: Option<Sha> = none();
48-
49-
// Conditions to stop looping are -- we know both the:
50-
//
51-
// - sha that the given tag points to
52-
// - exact version tag matching that sha
53-
//
54-
// These can be found in either order.
97+
const reducer = exactSemanticVersionTagReducer(givenTag);
5598

5699
for await (const response of octokit.paginate.iterator(
57100
octokit.rest.repos.listTags,
@@ -63,38 +106,9 @@ export async function findExactSemanticVersionTag(
63106
)) {
64107
// NOTE: we are not parsing here, so this is an unlawful type cast
65108
for (const tag of response.data as unknown as TagsResponse) {
66-
const sha = tag.commit.sha;
67-
const version = tag.name;
68-
69-
// If we found the sha the given tag points to
70-
if (version === givenTag) {
71-
givenTagSha = some(sha);
72-
// check if we already knew the exact version tag matching that sha
73-
const maybeExactTag = containsExactTag(versionsBySha[sha]);
74-
if (maybeExactTag !== undefined) {
75-
return maybeExactTag;
76-
}
77-
}
78-
79-
// If we're not looking at the given tag, and we're not looking
80-
// at an exact version, this data is of no use to us.
81-
if (!isExactSemanticVersion(version)) {
82-
continue;
83-
}
84-
85-
// It is possible that we know the sha for the given tag,
86-
// we're just looking for exact version tag matching that sha.
87-
if (isEqual(givenTagSha, sha)) {
88-
return version;
89-
}
90-
91-
// Otherwise, record this map of sha -> exact version tag
92-
// so we can find it when we know the sha of the given tag.
93-
const associatedVersions = versionsBySha[sha];
94-
if (associatedVersions === undefined) {
95-
versionsBySha[sha] = [version];
96-
} else {
97-
associatedVersions.push(version);
109+
const maybeExactTag = reducer(tag);
110+
if (isSome(maybeExactTag)) {
111+
return maybeExactTag.value;
98112
}
99113
}
100114
}

src/option.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
export type Option<A> = { tag: "none" } | { tag: "some"; value: A };
1+
export type None = { tag: "none" };
2+
export type Some<A> = { tag: "some"; value: A };
3+
export type Option<A> = Some<A> | None;
24

35
export function none<A>(): Option<A> {
46
return { tag: "none" };
@@ -8,6 +10,14 @@ export function some<A>(value: A): Option<A> {
810
return { tag: "some", value };
911
}
1012

13+
export function isNone<A>(value: Option<A>): value is None {
14+
return value.tag === "none";
15+
}
16+
17+
export function isSome<A>(value: Option<A>): value is Some<A> {
18+
return value.tag === "some";
19+
}
20+
1121
export function isEqual<A>(value: Option<A>, to: A): boolean {
1222
if (value.tag === "none") {
1323
return false;

src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ export type ExactSemanticVersion = string & { readonly __tag: unique symbol };
1212
export function isExactSemanticVersion(
1313
value: string
1414
): value is ExactSemanticVersion {
15-
const regex = /^v\d+\.\d+\.\d+$/;
15+
const regex =
16+
/^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-]+)*))?$/;
1617
return regex.test(value);
1718
}
1819

test/beta-workflow.json

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
[
2+
{
3+
"name": "v1.1.0-beta.1",
4+
"zipball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/zipball/refs/tags/v1.1.0-beta.1",
5+
"tarball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/tarball/refs/tags/v1.1.0-beta.1",
6+
"commit": {
7+
"sha": "2ba4cb142d0d5f6db85707fe55623b46c48cd6ac",
8+
"url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/commits/2ba4cb142d0d5f6db85707fe55623b46c48cd6ac"
9+
},
10+
"node_id": "REF_kwDOI53GybdyZWZzL3RhZ3MvdjEuMS4wLWJldGEuMQ"
11+
},
12+
{
13+
"name": "v1.1",
14+
"zipball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/zipball/refs/tags/v1.1",
15+
"tarball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/tarball/refs/tags/v1.1",
16+
"commit": {
17+
"sha": "2ba4cb142d0d5f6db85707fe55623b46c48cd6ac",
18+
"url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/commits/2ba4cb142d0d5f6db85707fe55623b46c48cd6ac"
19+
},
20+
"node_id": "REF_kwDOI53Gya5yZWZzL3RhZ3MvdjEuMQ"
21+
},
22+
{
23+
"name": "v1.0.1",
24+
"zipball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/zipball/refs/tags/v1.0.1",
25+
"tarball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/tarball/refs/tags/v1.0.1",
26+
"commit": {
27+
"sha": "eb500cb90cc7c3a6e3c1e6352e76fbc74c35b42c",
28+
"url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/commits/eb500cb90cc7c3a6e3c1e6352e76fbc74c35b42c"
29+
},
30+
"node_id": "REF_kwDOI53GybByZWZzL3RhZ3MvdjEuMC4x"
31+
},
32+
{
33+
"name": "v1.0.0",
34+
"zipball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/zipball/refs/tags/v1.0.0",
35+
"tarball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/tarball/refs/tags/v1.0.0",
36+
"commit": {
37+
"sha": "454e12d96ed333e1e09ecad01f7e0351b630af45",
38+
"url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/commits/454e12d96ed333e1e09ecad01f7e0351b630af45"
39+
},
40+
"node_id": "REF_kwDOI53GybByZWZzL3RhZ3MvdjEuMC4w"
41+
},
42+
{
43+
"name": "v1.0",
44+
"zipball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/zipball/refs/tags/v1.0",
45+
"tarball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/tarball/refs/tags/v1.0",
46+
"commit": {
47+
"sha": "eb500cb90cc7c3a6e3c1e6352e76fbc74c35b42c",
48+
"url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/commits/eb500cb90cc7c3a6e3c1e6352e76fbc74c35b42c"
49+
},
50+
"node_id": "REF_kwDOI53Gya5yZWZzL3RhZ3MvdjEuMA"
51+
},
52+
{
53+
"name": "v1",
54+
"zipball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/zipball/refs/tags/v1",
55+
"tarball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/tarball/refs/tags/v1",
56+
"commit": {
57+
"sha": "2ba4cb142d0d5f6db85707fe55623b46c48cd6ac",
58+
"url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-assets/commits/2ba4cb142d0d5f6db85707fe55623b46c48cd6ac"
59+
},
60+
"node_id": "REF_kwDOI53GyaxyZWZzL3RhZ3MvdjE"
61+
}
62+
]
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
[
2+
{
3+
"name": "v1.0.8",
4+
"zipball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/zipball/refs/tags/v1.0.8",
5+
"tarball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/tarball/refs/tags/v1.0.8",
6+
"commit": {
7+
"sha": "aaebf5c572b64d17710f936905c1b35e1f58acc8",
8+
"url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/commits/aaebf5c572b64d17710f936905c1b35e1f58acc8"
9+
},
10+
"node_id": "REF_kwDOIweDT7ByZWZzL3RhZ3MvdjEuMC44"
11+
},
12+
{
13+
"name": "v1.0.7",
14+
"zipball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/zipball/refs/tags/v1.0.7",
15+
"tarball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/tarball/refs/tags/v1.0.7",
16+
"commit": {
17+
"sha": "7382f9a3ce14be1fd8b3656d262fc2c720c8f51c",
18+
"url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/commits/7382f9a3ce14be1fd8b3656d262fc2c720c8f51c"
19+
},
20+
"node_id": "REF_kwDOIweDT7ByZWZzL3RhZ3MvdjEuMC43"
21+
},
22+
{
23+
"name": "v1.0.0",
24+
"zipball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/zipball/refs/tags/v1.0.0",
25+
"tarball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/tarball/refs/tags/v1.0.0",
26+
"commit": {
27+
"sha": "1e2a9b5bdb6012c863e6cb4b5b69897e0bb2cb43",
28+
"url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/commits/1e2a9b5bdb6012c863e6cb4b5b69897e0bb2cb43"
29+
},
30+
"node_id": "REF_kwDOIweDT7ByZWZzL3RhZ3MvdjEuMC4w"
31+
},
32+
{
33+
"name": "v1.0",
34+
"zipball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/zipball/refs/tags/v1.0",
35+
"tarball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/tarball/refs/tags/v1.0",
36+
"commit": {
37+
"sha": "aaebf5c572b64d17710f936905c1b35e1f58acc8",
38+
"url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/commits/aaebf5c572b64d17710f936905c1b35e1f58acc8"
39+
},
40+
"node_id": "REF_kwDOIweDT65yZWZzL3RhZ3MvdjEuMA"
41+
},
42+
{
43+
"name": "v1",
44+
"zipball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/zipball/refs/tags/v1",
45+
"tarball_url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/tarball/refs/tags/v1",
46+
"commit": {
47+
"sha": "aaebf5c572b64d17710f936905c1b35e1f58acc8",
48+
"url": "https://api.github.com/repos/EricCrosson/configure-semantic-release-manifest/commits/aaebf5c572b64d17710f936905c1b35e1f58acc8"
49+
},
50+
"node_id": "REF_kwDOIweDT6xyZWZzL3RhZ3MvdjE"
51+
}
52+
]
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 { exactSemanticVersionTagReducer, 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 = exactSemanticVersionTagReducer(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)