Skip to content

Commit 0c5eab2

Browse files
feat: add automatic release version action (#52)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cce28fc commit 0c5eab2

1 file changed

Lines changed: 195 additions & 0 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
name: 'Determine Next Version'
2+
description: 'Determine the next semantic version since the latest stable GitHub release'
3+
4+
inputs:
5+
github-token:
6+
description: GitHub token used to read releases, tags, commits, and pull requests
7+
required: true
8+
target-repo:
9+
description: Repository containing the releases and pull requests
10+
required: true
11+
target-branch:
12+
description: Branch receiving release pull requests
13+
required: false
14+
default: main
15+
tag-prefix:
16+
description: Optional accepted prefix for version tags, such as "v"
17+
required: false
18+
default: ''
19+
20+
outputs:
21+
version:
22+
description: The next semantic version without the tag prefix
23+
value: ${{ steps.release.outputs.version }}
24+
previous-version:
25+
description: The latest stable released semantic version
26+
value: ${{ steps.release.outputs.previous-version }}
27+
bump:
28+
description: The detected semantic version bump
29+
value: ${{ steps.release.outputs.bump }}
30+
release-needed:
31+
description: Whether merged pull requests require a release
32+
value: ${{ steps.release.outputs.release-needed }}
33+
34+
runs:
35+
using: composite
36+
steps:
37+
- name: Determine release type
38+
id: release
39+
uses: actions/github-script@v9
40+
env:
41+
TARGET_REPO: ${{ inputs.target-repo }}
42+
TARGET_BRANCH: ${{ inputs.target-branch }}
43+
TAG_PREFIX: ${{ inputs.tag-prefix }}
44+
with:
45+
github-token: ${{ inputs.github-token }}
46+
script: |
47+
const [owner, repo] = process.env.TARGET_REPO.split('/');
48+
const branch = process.env.TARGET_BRANCH;
49+
const prefix = process.env.TAG_PREFIX;
50+
const parseVersion = name => {
51+
const version = prefix && name.startsWith(prefix)
52+
? name.slice(prefix.length)
53+
: name;
54+
return /^\d+\.\d+\.\d+$/.test(version) ? version : null;
55+
};
56+
const compareVersions = (left, right) => {
57+
const leftParts = left.split('.').map(Number);
58+
const rightParts = right.split('.').map(Number);
59+
for (let index = 0; index < leftParts.length; index++) {
60+
if (leftParts[index] !== rightParts[index]) {
61+
return leftParts[index] - rightParts[index];
62+
}
63+
}
64+
return 0;
65+
};
66+
67+
const [releases, tags] = await Promise.all([
68+
github.paginate(github.rest.repos.listReleases, {
69+
owner,
70+
repo,
71+
per_page: 100,
72+
}),
73+
github.paginate(github.rest.repos.listTags, {
74+
owner,
75+
repo,
76+
per_page: 100,
77+
}),
78+
]);
79+
const releaseTagNames = new Set(
80+
releases
81+
.filter(release => !release.draft && !release.prerelease)
82+
.map(release => release.tag_name),
83+
);
84+
85+
const tagsByCommit = new Map();
86+
for (const tag of tags) {
87+
if (!releaseTagNames.has(tag.name)) continue;
88+
89+
const version = parseVersion(tag.name);
90+
if (!version) continue;
91+
92+
const commitTags = tagsByCommit.get(tag.commit.sha) || [];
93+
commitTags.push({ name: tag.name, version });
94+
tagsByCommit.set(tag.commit.sha, commitTags);
95+
}
96+
97+
let latestTag = null;
98+
let tagPage = 1;
99+
while (!latestTag) {
100+
const { data: branchCommits } = await github.rest.repos.listCommits({
101+
owner,
102+
repo,
103+
sha: branch,
104+
per_page: 100,
105+
page: tagPage,
106+
});
107+
if (branchCommits.length === 0) break;
108+
109+
for (const commit of branchCommits) {
110+
const commitTags = tagsByCommit.get(commit.sha);
111+
if (!commitTags) continue;
112+
113+
latestTag = commitTags.reduce((latest, candidate) =>
114+
compareVersions(candidate.version, latest.version) > 0
115+
? candidate
116+
: latest);
117+
break;
118+
}
119+
tagPage++;
120+
}
121+
122+
if (!latestTag) {
123+
core.setFailed(
124+
`Could not find a stable GitHub release tag on ${branch}`,
125+
);
126+
return;
127+
}
128+
129+
const commits = [];
130+
let page = 1;
131+
let totalCommits;
132+
do {
133+
const { data: comparison } =
134+
await github.rest.repos.compareCommitsWithBasehead({
135+
owner,
136+
repo,
137+
basehead: `${latestTag.name}...${branch}`,
138+
per_page: 100,
139+
page,
140+
});
141+
commits.push(...comparison.commits);
142+
totalCommits = comparison.total_commits;
143+
page++;
144+
} while (commits.length < totalCommits);
145+
146+
const mergedPulls = new Map();
147+
for (const commit of commits) {
148+
const pulls = await github.paginate(
149+
github.rest.repos.listPullRequestsAssociatedWithCommit,
150+
{
151+
owner,
152+
repo,
153+
commit_sha: commit.sha,
154+
per_page: 100,
155+
},
156+
);
157+
for (const pull of pulls) {
158+
if (pull.merged_at && pull.base.ref === branch) {
159+
mergedPulls.set(pull.number, pull);
160+
}
161+
}
162+
}
163+
const titles = [...mergedPulls.values()]
164+
.map(({ title }) => title.trim());
165+
166+
const matches = pattern => titles.some(title => pattern.test(title));
167+
const bump = matches(/^[\w-]+(?:\([^)]+\))?!:/i) ? 'major'
168+
: matches(/^feat(?:\([^)]+\))?:/i) ? 'minor'
169+
: matches(/^(?:fix|perf)(?:\([^)]+\))?:/i) ? 'patch'
170+
: null;
171+
172+
core.setOutput('previous-version', latestTag.version);
173+
if (!bump) {
174+
core.info(
175+
`No releasable pull requests found after ${latestTag.name}`,
176+
);
177+
core.setOutput('release-needed', 'false');
178+
return;
179+
}
180+
181+
const previousVersion = latestTag.version;
182+
const [major, minor, patch] = previousVersion.split('.').map(Number);
183+
const nextVersion = {
184+
major: `${major + 1}.0.0`,
185+
minor: `${major}.${minor + 1}.0`,
186+
patch: `${major}.${minor}.${patch + 1}`,
187+
}[bump];
188+
189+
core.info(
190+
`Determined ${bump} release ${previousVersion} → ${nextVersion} ` +
191+
`from ${titles.length} merged pull requests`,
192+
);
193+
core.setOutput('version', nextVersion);
194+
core.setOutput('bump', bump);
195+
core.setOutput('release-needed', 'true');

0 commit comments

Comments
 (0)