Skip to content

Commit c8dae11

Browse files
authored
feat: add complete release process (#2)
* feat: add better error messages on `tagReleaseCandidate` * feat: add git commiter identity * fix: correct input names and add error message to create release fn * fix: get correct version and commit url * fix: improve error messages * fix: close log groups and add more logging * fix: correct release payload * fix: correct fields names * feat: add release candidate option * fix: improve error messages * fix: improve error logs on git operations * fix: add semver guards * fix: debug * fix: debug * fix: debug * fix: debug * fix: remove console log * fix: improve version checks * fix: correct git sorting on changelog gen * chore: update dist * fix: correct log grouping
1 parent 4246b9a commit c8dae11

File tree

8 files changed

+398
-97
lines changed

8 files changed

+398
-97
lines changed

action.yml

+12
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ inputs:
55
github-token:
66
required: true
77
description: GitHub personal access token
8+
git-committer-name:
9+
required: true
10+
description: Git committer name
11+
default: Publish SemVer release action
12+
git-committer-email:
13+
required: true
14+
description: Git committer email
15+
16+
release-candidate:
17+
required: false
18+
description: Determines if the release is a release candidate
19+
default: 'true'
820
outputs:
921
next-version:
1022
description: Next version to be released

dist/index.js

+216-43
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/changelog.ts

+28-13
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { getInput } from '@actions/core'
22
import { getExecOutput } from '@actions/exec'
33
import { getOctokit } from '@actions/github'
44
import type { Context } from '@actions/github/lib/context'
5+
import * as core from '@actions/core'
6+
import { getLastGitTag } from './git'
57
import type { ReleaseType } from './version'
68
import { getReleaseTypeFromCommitMessage } from './version'
79

@@ -14,24 +16,32 @@ ReturnType<typeof getOctokit>['rest']['repos']['listCommits']
1416

1517
const run = async(command: string) => (await getExecOutput(command)).stdout
1618

17-
const getLastCommits = async(context: Context) => {
19+
const getLastCommits = async(context: Context, considerReleaseCandidates: boolean) => {
1820
const githubToken = getInput('github-token') || process.env.GH_TOKEN
1921
if (githubToken === '' || githubToken === undefined)
2022
throw new Error('GitHub token is required')
2123

2224
const github = getOctokit(githubToken).rest
2325

2426
// get the sha of the last tagged commit
25-
const lastTag = await run('git describe --tags --abbrev=0')
27+
const lastTag = await getLastGitTag(considerReleaseCandidates)
2628
const lastTaggedCommitSha = await run(`git rev-list -n 1 ${lastTag}`)
29+
const lastTaggedCommitDate = await run(`git show -s --format=%ci ${lastTaggedCommitSha}`)
30+
core.info(`Getting commits since ${lastTaggedCommitDate} [${lastTag}](${lastTaggedCommitSha})`)
2731

2832
const { data: commits } = await github.repos.listCommits({
2933
owner: context.repo.owner,
3034
repo: context.repo.repo,
35+
since: lastTaggedCommitDate,
3136
})
3237

38+
const commitsSortedByDateDesc = commits.sort((a, b) => {
39+
const aDate = new Date(String(a.commit.author?.date))
40+
const bDate = new Date(String(b.commit.author?.date))
41+
return bDate.getTime() - aDate.getTime()
42+
})
3343
const lastCommits = []
34-
for (const commit of commits) {
44+
for (const commit of commitsSortedByDateDesc) {
3545
if (commit.sha === lastTaggedCommitSha)
3646
break
3747
lastCommits.push(commit)
@@ -43,7 +53,8 @@ const getLastCommits = async(context: Context) => {
4353
const groupCommitsByReleaseType = (commits: CommitList) => {
4454
return commits
4555
.map((commit) => {
46-
const { message, url, author } = commit.commit
56+
const { html_url: url } = commit
57+
const { message, author } = commit.commit
4758
const type = getReleaseTypeFromCommitMessage(message)
4859
return { message, type, url, author: String(author?.name) }
4960
})
@@ -60,8 +71,8 @@ const groupCommitsByReleaseType = (commits: CommitList) => {
6071
const formatCommitsByType = (commitsByType: CommitsByReleaseType) => {
6172
let changelog = ''
6273
const getCommitInfo = (commit: { message: string; url: string; author: string }) => {
63-
const message = commit.message.split(':')[1].trim()
64-
const scope = commit.message.match(/^(.*?): /)?.[1] ?? ''
74+
const message = commit.message.split(':')[1].split('\n').shift()?.trim()
75+
const scope = commit.message.match(/\(([^/)]+)\):/)?.[1] ?? ''
6576
const commitSha = commit.url.split('/').pop()?.slice(0, 8)
6677
return { message, scope, commitSha }
6778
}
@@ -73,30 +84,34 @@ const formatCommitsByType = (commitsByType: CommitsByReleaseType) => {
7384
}
7485
if (commitsByType.minor) {
7586
if (!commitsByType.major)
76-
changelog += '### Features\n'
87+
changelog += '\n### Features\n'
7788

7889
const featureCommits = [
7990
...(commitsByType.major || []),
8091
...(commitsByType.minor || []),
8192
]
8293
for (const commit of featureCommits) {
8394
const { message, scope, commitSha } = getCommitInfo(commit)
84-
changelog += `- **(${scope})** ${message} ([${commitSha}](${commit.url}))\n`
95+
changelog += `- ${scope ? `**(${scope})**` : ''} ${message} ([${commitSha}](${commit.url}))\n`
8596
}
8697
}
8798
if (commitsByType.patch) {
88-
changelog += '### Bug Fixes\n'
99+
changelog += '\n### Bug Fixes\n'
89100
for (const commit of commitsByType.patch) {
90101
const { message, scope, commitSha } = getCommitInfo(commit)
91-
changelog += `- **(${scope})** ${message} ([${commitSha}](${commit.url}))\n`
102+
changelog += `- ${scope ? `**(${scope})**` : ''} ${message} ([${commitSha}](${commit.url}))\n`
92103
}
93104
}
94105

95106
return changelog
96107
}
97108

98-
export const generateChangelog = async(context: Context) => {
99-
const lastCommits = await getLastCommits(context)
109+
export const generateChangelog = async(context: Context, considerReleaseCandidates: boolean) => {
110+
core.startGroup('Generating changelog')
111+
const lastCommits = await getLastCommits(context, considerReleaseCandidates)
100112
const commitsByType = groupCommitsByReleaseType(lastCommits)
101-
return formatCommitsByType(commitsByType)
113+
const formattedChangelog = formatCommitsByType(commitsByType)
114+
core.info(formattedChangelog)
115+
core.endGroup()
116+
return formattedChangelog
102117
}

src/git.ts

+90-16
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,40 @@
11
import { getExecOutput } from '@actions/exec'
22
import * as core from '@actions/core'
33

4-
export const getLastGitTag = async(): Promise<string | null> => {
4+
export const getLastGitTag = async(considerReleaseCandidates: boolean, logInGroup = false): Promise<string | null> => {
55
try {
6-
core.startGroup('Getting last git tag')
7-
const { stdout: lastGitTag, exitCode } = await getExecOutput(
8-
'git describe --tags --abbrev=0',
9-
[],
10-
{ silent: true },
6+
if (logInGroup)
7+
core.startGroup('Getting last git tag')
8+
const { stdout: gitTagList, exitCode } = await getExecOutput(
9+
'git for-each-ref --sort=creatordate --format "%(refname)" refs/tags',
1110
)
1211
if (exitCode !== 0)
1312
throw Error
14-
core.endGroup()
13+
const filteredTags = gitTagList
14+
.trim()
15+
.split('\n')
16+
.filter(ref =>
17+
// Ensure that the line isn't empty, then check against
18+
// the release candidate option input
19+
Boolean(ref)
20+
&& ref.split('/').at(-1)?.match(/^v?\d+\.\d+\.\d+(-[\w\d]+)?$/) !== null
21+
&& considerReleaseCandidates
22+
? true
23+
: !ref.includes('-rc'),
24+
)
25+
.reverse()
26+
const lastGitTag = filteredTags
27+
.at(0)
28+
?.split('/')
29+
.at(-1)
30+
31+
if (lastGitTag === undefined || lastGitTag === '') {
32+
core.info('No git tag found.')
33+
throw Error
34+
}
35+
core.info(`Last git tag: ${lastGitTag}`)
36+
if (logInGroup)
37+
core.endGroup()
1538
return lastGitTag
1639
}
1740
catch (e) {
@@ -25,12 +48,10 @@ export const getLastCommitMessage = async(): Promise<string | null> => {
2548
core.startGroup('Getting last commit message')
2649
const { stdout: lastCommitMessage, exitCode } = await getExecOutput(
2750
'git log -1 --pretty=%B --no-merges',
28-
[],
29-
{ silent: true },
3051
)
3152
if (exitCode !== 0)
3253
throw Error
33-
54+
core.endGroup()
3455
return lastCommitMessage
3556
}
3657
catch (e) {
@@ -39,28 +60,81 @@ export const getLastCommitMessage = async(): Promise<string | null> => {
3960
}
4061
}
4162

42-
export const tagReleaseCandidate = async(nextVersion: string): Promise<void | null> => {
63+
const setGitCommiter = async(): Promise<void> => {
4364
try {
44-
core.startGroup('Tagging release candidate')
45-
const { exitCode: tagExitCode } = await getExecOutput(
46-
`git tag -a ${nextVersion}-rc -m "Release candidate for ${nextVersion}"`,
65+
const name = core.getInput('git-committer-name')
66+
const email = core.getInput('git-committer-email')
67+
if (name === '' || email === '')
68+
throw new Error('Git committer name and email are required')
69+
70+
core.startGroup('Setting git commiter identity')
71+
const { exitCode: exitCodeName } = await getExecOutput(
72+
`git config --global user.name "${name}"`,
73+
[],
74+
{ silent: true },
75+
)
76+
if (exitCodeName !== 0)
77+
throw new Error('Could not set git commiter name')
78+
79+
const { exitCode: exitCodeEmail } = await getExecOutput(
80+
`git config --global user.email "${email}"`,
4781
[],
4882
{ silent: true },
4983
)
84+
if (exitCodeEmail !== 0)
85+
throw new Error('Could not set git commiter email')
86+
core.info('Git commiter identity set.')
87+
core.endGroup()
88+
}
89+
catch (e: any) {
90+
core.error(`Could not set git commiter identity\n${e.message}`)
91+
}
92+
}
93+
94+
export const tagCommit = async(nextVersion: string, isReleaseCandidate: boolean): Promise<void | null> => {
95+
try {
96+
await setGitCommiter()
97+
core.startGroup(`Tagging ${isReleaseCandidate ? 'release candidate' : 'version'} ${nextVersion}`)
98+
const version = `${nextVersion}${isReleaseCandidate ? '-rc' : ''}`
99+
const { exitCode: tagExitCode } = await getExecOutput(
100+
`git tag -a ${version} -m "Release ${nextVersion}"`,
101+
)
50102
if (tagExitCode !== 0)
51103
throw Error
52104

53105
const { exitCode: pushExitCode } = await getExecOutput(
54106
'git push --tags',
55-
[],
56-
{ silent: true },
57107
)
58108
if (pushExitCode !== 0)
59109
throw Error
110+
core.info(`Git tag ${version} pushed.`)
111+
core.endGroup()
112+
}
113+
catch (e) {
114+
core.error('Could not tag commit')
115+
return null
116+
}
117+
}
118+
119+
export const deleteTag = async(tag: string): Promise<void | null> => {
120+
try {
121+
core.startGroup('Deleting tag')
122+
const { exitCode: deleteTagExitCode } = await getExecOutput(
123+
`git tag -d ${tag}`,
124+
)
125+
if (deleteTagExitCode !== 0)
126+
throw Error
60127

128+
const { exitCode: pushExitCode } = await getExecOutput(
129+
`git push --delete origin ${tag}`,
130+
)
131+
if (pushExitCode !== 0)
132+
throw Error
133+
core.info(`Git tag ${tag} deleted.`)
61134
core.endGroup()
62135
}
63136
catch (e) {
137+
core.error('Could not delete tag')
64138
return null
65139
}
66140
}

src/github.ts

+35-17
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,46 @@
11
import { getInput } from '@actions/core'
22
import { getOctokit } from '@actions/github'
3+
import * as core from '@actions/core'
34
import type { Context } from '@actions/github/lib/context'
5+
import { deleteTag } from './git'
46

5-
export const createGithubRelease = async(context: Context, nextVersion: string, body: string) => {
7+
export const createGithubRelease = async(
8+
context: Context,
9+
nextVersion: string,
10+
body: string,
11+
isReleaseCandidate: boolean,
12+
) => {
13+
core.startGroup('Creating GitHub release')
614
const githubToken = getInput('github-token') || process.env.GH_TOKEN
715
if (githubToken === '' || githubToken === undefined)
816
throw new Error('GitHub token is required')
917

1018
const client = getOctokit(githubToken).rest
19+
const version = isReleaseCandidate ? `${nextVersion}-rc` : nextVersion
1120

12-
const {
13-
data: {
14-
url: releaseUrl,
15-
},
16-
} = await client.repos.createRelease(
17-
{
18-
repo: context.repo.owner,
19-
owner: context.repo.repo,
20-
tag_name: nextVersion,
21-
name: nextVersion,
22-
body,
23-
prerelease: true,
24-
},
25-
)
26-
27-
return releaseUrl
21+
try {
22+
const {
23+
data: {
24+
html_url: releaseUrl,
25+
},
26+
} = await client.repos.createRelease(
27+
{
28+
repo: context.repo.repo,
29+
owner: context.repo.owner,
30+
tag_name: version,
31+
name: version,
32+
body,
33+
prerelease: isReleaseCandidate,
34+
},
35+
)
36+
core.info(`Created release at ${releaseUrl}`)
37+
core.endGroup()
38+
return releaseUrl
39+
}
40+
catch (e: any) {
41+
core.info('Could not create GitHub release')
42+
core.endGroup()
43+
await deleteTag(`${nextVersion}`)
44+
core.error(`${e.status} - ${e.message}`)
45+
}
2846
}

src/main.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import * as core from '@actions/core'
22
import { context } from '@actions/github'
33
import { generateChangelog } from './changelog'
4-
import { getLastCommitMessage, getLastGitTag, tagReleaseCandidate } from './git'
4+
import { getLastCommitMessage, getLastGitTag, tagCommit } from './git'
55
import { createGithubRelease } from './github'
66
import { getNextVersion, getReleaseTypeFromCommitMessage } from './version'
77

88
async function run(): Promise<void> {
9+
const isReleaseCandidate = core.getInput('release-candidate') === 'true'
10+
911
try {
10-
const lastVersion = await getLastGitTag()
12+
const lastVersion = await getLastGitTag(isReleaseCandidate, true)
1113
if (lastVersion === null)
1214
return
1315

@@ -22,13 +24,12 @@ async function run(): Promise<void> {
2224
const nextVersion = getNextVersion(lastVersion, releaseType)
2325
core.info(`Publishing a release candidate for version ${nextVersion}`)
2426

25-
const changelog = await generateChangelog(context)
26-
core.info(changelog)
27+
const changelog = await generateChangelog(context, isReleaseCandidate)
2728

2829
// Tag commit with the next version release candidate
29-
await tagReleaseCandidate(nextVersion)
30+
await tagCommit(nextVersion, isReleaseCandidate)
3031

31-
await createGithubRelease(context, `${nextVersion}-rc`, changelog)
32+
await createGithubRelease(context, nextVersion, changelog, isReleaseCandidate)
3233

3334
core.setOutput('next-version', nextVersion)
3435
core.setOutput('release-type', releaseType)

src/version.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as core from '@actions/core'
12
export type ReleaseType = 'patch' | 'minor' | 'major'
23

34
export const getReleaseTypeFromCommitMessage = (commitMessage: string): ReleaseType | null => {
@@ -11,7 +12,14 @@ export const getReleaseTypeFromCommitMessage = (commitMessage: string): ReleaseT
1112
}
1213

1314
export const getNextVersion = (currentVersion: string, releaseType: ReleaseType): string => {
14-
const [major, minor, patch] = currentVersion.split('.').map(Number)
15+
// verify that the current version is valid semver
16+
if (currentVersion.match(/^\d+\.\d+\.\d+(-[\w\d]+)?$/) === null) {
17+
core.error(`Invalid current version: ${currentVersion}`)
18+
throw Error
19+
}
20+
21+
const pureVersion = currentVersion.split('-')[0]
22+
const [major, minor, patch] = pureVersion.split('.').map(Number)
1523
return ({
1624
major: () => `${major + 1}.0.0`,
1725
minor: () => `${major}.${minor + 1}.0`,

0 commit comments

Comments
 (0)