I’m a big fan of semantic-release since it appeared. I followed its development, studied its inners. I made in-house reports, held workshops and finally brought semrel to our build infrastructure. I wrote plugins, plugin-factories and testing-tools for it. For several years now, I've been trying to combine semantic releases and monorepositories in many OSS projects:
- https://github.com/qiwi/semantic-release-monorepo-hooks
- https://github.com/dhoulb/multi-semantic-release
- https://github.com/qiwi/multi-semantic-release
Etc, etc, so on. I’m just trying to say, that semrel had a significant impact on my professional life.
Semrel goal
The main purpose of semantic-release is to transform semantic (conventional) commits into build artifacts and deployments. With version bumping, changelogs, tagging, pkg publishing. “Fully-automated release” — is the true. There are also dozens on plugins, so you’ll most likely find a solution for any standard case. It really saves times.
But sometimes
You may need a minor tweak up. For example, push some pkg to both public and internal registries. Ooops. "...publishing to two different registry is not a good idea". In this case you can not rely on stable, convenient and tested in millions runs semrel/npm plugin, and you have to just write a pair of commands by hand with semantic-release/exec instead:
echo "//npm-registry.domain.com/:_authToken=${TOKEN}” >> .npmrc
echo "\`jq '.name="@scope/pkg-name”’ package.json\`" > package.json
npm config set registry https://npm-registry.domain.com
npm publish --no-git-tag-version
Another instance — disabling git notes fetching. "Afraid that won't be possible".
Of course, you may fork semrel and remove the mentioned line. Or create a plugin/hook, that will override loaded execa module with patched one version, than just skips git notes
invocation (this is really frustrating, I did smth similar). Or… {{ another crazy workaround goes here }}
.
This is a watershed moment. Once you start to fight against the tool, it's time to just pick another one. The new dilemma:
- Spend days and days for searching, tuning and testing analogs.
- Write your own semantic-release.
My opinionated suggestion: if your case is very simple or, conversely, very complex, the second option will be optimal. Release script — is not a rocket science!
140 lines alternative
Let's take a look at what exactly each release consists of, if we discard the high-level tool contracts. I use zx in the examples, but it could be execa or native child_process.exec too.
1. Git configuration
To make a commit you need a committer: just name and email that will be associated with author. Also PAT or SSH token is required to push the commit.
const {GIT_COMMITTER_NAME, GIT_COMMITTER_EMAIL, GITHUB_TOKEN} = process.env
if (!GITHUB_TOKEN || !GIT_COMMITTER_NAME || !GIT_COMMITTER_EMAIL) {
throw new Error('env.GITHUB_TOKEN, env.GIT_COMMITTER_NAME & env.GIT_COMMITTER_EMAIL must be set')
}
const gitAuth = `${GIT_COMMITTER_NAME}:${GITHUB_TOKEN}`
const originUrl = (await $`git config --get remote.origin.url`).toString().trim()
const [,,repoHost, repoName] = originUrl.replace(':', '/').replace(/\.git/, '').match(/.+(@|\/\/)([^/]+)\/(.+)$/)
const repoPublicUrl = `https://${repoHost}/${repoName}`
const repoAuthedUrl = `https://${gitAuth}@${repoHost}/${repoName}`
await $`git config user.name ${GIT_COMMITTER_NAME}`
await $`git config user.email ${GIT_COMMITTER_EMAIL}`
await $`git remote set-url origin ${repoAuthedUrl}`
2. Commit analysis
Conventional commits are just a prefixed strings in git log. We should define some rules on how to associate messages substrings with corresponding release types:
const semanticTagPattern = /^(v?)(\d+)\.(\d+)\.(\d+)$/
const releaseSeverityOrder = ['major', 'minor', 'patch']
const semanticRules = [
{group: 'Features', releaseType: 'minor', prefixes: ['feat']},
{group: 'Fixes & improvements', releaseType: 'patch', prefixes: ['fix', 'perf', 'refactor', 'docs']},
{group: 'BREAKING CHANGES', releaseType: 'major', keywords: ['BREAKING CHANGE', 'BREAKING CHANGES']},
]
Then we search for the prev release tag, that satisfies semver pattern:
const tags = (await $`git tag -l --sort=-v:refname`).toString().split('\n').map(tag => tag.trim())
const lastTag = tags.find(tag => semanticTagPattern.test(tag))
And make commits cut from the found ref:
const newCommits = (lastTag
? await $`git log --format=+++%s__%b__%h__%H ${await $`git rev-list -1 ${lastTag}`}..HEAD`
: await $`git log --format=+++%s__%b__%h__%H HEAD`)
.toString()
.split('+++')
.filter(Boolean)
.map(msg => {
const [subj, body, short, hash] = msg.split('__').map(raw => raw.trim())
return {subj, body, short, hash}
})
Now we just need to parse them:
const semanticChanges = newCommits.reduce((acc, {subj, body, short, hash}) => {
semanticRules.forEach(({group, releaseType, prefixes, keywords}) => {
const prefixMatcher = prefixes && new RegExp(`^(${prefixes.join('|')})(\\(\\w+\\))?:\\s.+$`)
const keywordsMatcher = keywords && new RegExp(`(${keywords.join('|')}):\\s(.+)`)
const change = subj.match(prefixMatcher)?.[0] || body.match(keywordsMatcher)?.[2]
if (change) {
acc.push({
group,
releaseType,
change,
subj,
body,
short,
hash
})
}
})
return acc
}, [])
Ta-da. Semantic changes:
semanticChanges= [
{
group: 'Fixes & improvements',
releaseType: 'patch',
change: 'perf: use git for tags sorting',
subj: 'perf: use git for tags sorting',
body: '',
short: 'a1abdae',
hash: 'a1abdaea801824d0392e69f9182daf4d5f4b97db'
},
{
group: 'Fixes & improvements',
releaseType: 'patch',
change: 'refactor: minor simplifications',
subj: 'refactor: minor simplifications',
body: '',
short: 'be847a2',
hash: 'be847a26e2b0583e889403ec00db45f9f9555e30'
},
{
group: 'Fixes & improvements',
releaseType: 'patch',
change: 'fix: fix commit url template',
subj: 'fix: fix commit url template',
body: '',
short: '3669edd',
hash: '3669edd7eb440e29dc0fcf493c76fbfc04271023'
}
]
3. Resolve next version:
const nextReleaseType = releaseSeverityOrder.find(type => semanticChanges.find(({releaseType}) => type === releaseType))
if (!nextReleaseType) {
console.log('No semantic changes - no semantic release.')
return
}
const nextVersion = ((lastTag, releaseType) => {
if (!releaseType) {
return
}
if (!lastTag) {
return '1.0.0'
}
const [, , c1, c2, c3] = semanticTagPattern.exec(lastTag)
if (releaseType === 'major') {
return `${-~c1}.0.0`
}
if (releaseType === 'minor') {
return `${c1}.${-~c2}.0`
}
if (releaseType === 'patch') {
return `${c1}.${c2}.${-~c3}`
}
})(lastTag, nextReleaseType)
const nextTag = 'v' + nextVersion
4. Generate release notes
const releaseDiffRef = `## [${nextVersion}](${repoPublicUrl}/compare/${lastTag}...${nextTag}) (${new Date().toISOString().slice(0, 10)})`
const releaseDetails = Object.values(semanticChanges
.reduce((acc, {group, change, short, hash}) => {
const {commits} = acc[group] || (acc[group] = {commits: [], group})
const commitRef = `* ${change} ([${short}](${repoPublicUrl}/commit/${hash}))`
commits.push(commitRef)
return acc
}, {}))
.map(({group, commits}) => `
### ${group}
${commits.join('\n')}`).join('\n')
const releaseNotes = releaseDiffRef + '\n' + releaseDetails + '\n'
5. Update CHANGELOG.md
Attach releaseNotes
to file. Just one string.
await $`echo ${releaseNotes}"\n$(cat ./CHANGELOG.md)" > ./CHANGELOG.md`
6. Update package version
await $`npm --no-git-tag-version version ${nextVersion}`
7. Git release.
Create commit. Create tag. Push them.
const releaseMessage = `chore(release): ${nextVersion} [skip ci]`
await $`git add -A .`
await $`git commit -am ${releaseMessage}`
await $`git tag -a ${nextTag} HEAD -m ${releaseMessage}`
await $`git push --follow-tags origin HEAD:refs/heads/master`
8. GitHub release
Just one curl POST to gh rest api.
const releaseData = JSON.stringify({
name: nextTag,
tag_name: nextTag,
body: releaseNotes
})
await $`curl -u ${GIT_COMMITTER_NAME}:${GITHUB_TOKEN} -H "Accept: application/vnd.github.v3+json" https://api.github.com/repos/${repoName}/releases -d ${releaseData}`
9. Npm publish
await $`npm publish --no-git-tag-version`
Need several registries? NP.
await $`npm config set registry https://registry.npmjs.org`
await $`npm publish --no-git-tag-version`
await $`echo "\`jq '.name="@${repoName}"' package.json\`" > package.json`
await $`npm config set registry https://npm.pkg.github.com`
await $`npm publish --no-git-tag-version`
Conclusions
This solution does not cover corner cases and has significant limitations of usage. Ultimately, you don't care if other tools have 99.99999% applicability until they ignore just one specific case — yours. But now you have completely taken back release flow control. You're able to improve and modify this snippet as you wish and whenever you like.
release.mjs
gh release.yaml
release log
semrel-extra / zx-semrel
`zx`-based release script as `semantic-release` alternative (PoC)
zx-semrel
zx -based release script as semantic-release alternative (PoC)
Sometimes bloody enterprise enforces you not to use any third-party solutions for sensitive operations (like release, deploy, so on) Old good script copy-paste hurries to the rescue!
Btw, here's an adaptation for monorepos: zx-bulk-release
Requirements
- macOS / linux
- Node.js >= 14.13.1
- git >= 2.0
- zx >= 1.6.0
Key features
- Zero dependencies
- Zero configuration
- Pretty fast
- Tiny, less than 140 lines with comments
- Reliability, safety, simplicity and maintainability (sarcasm)
Functionality
- Poor conventional commits analysis
-
CHANGELOG.md
generation -
package.json
version bumping - Git release commit creation
- GitHub Release
- Package publishing to both npmjs and gh registries
🚀 Usage
- Copy
- Tweak up, inject tokens, etc
- Run
curl https://raw.githubusercontent.com/semrel-extra/zx-semrel/master/release.mjs > ./release.mjs
zx ./release.mjs
or this like if zx
is not installed:
# Just replace GIT* env values with your own
GIT_COMMITTER_NAME=antongolub GIT_COMMITER_EMAIL=mailbox@antongolub.ru GITHUB_TOKEN=token npx zx ./release.mjs
or just run it without any edits though npx:
Top comments (0)