Actually you don’t need 'semantic-release' for semantic release

antongolub

Anton Golub

Posted on May 23, 2021

Actually you don’t need 'semantic-release' for semantic release

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:

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


Enter fullscreen mode Exit fullscreen mode

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:

  1. Spend days and days for searching, tuning and testing analogs.
  2. 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}`


Enter fullscreen mode Exit fullscreen mode

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']},
]


Enter fullscreen mode Exit fullscreen mode

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))


Enter fullscreen mode Exit fullscreen mode

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}
  })


Enter fullscreen mode Exit fullscreen mode

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
}, [])


Enter fullscreen mode Exit fullscreen mode

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'
  }
]


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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'


Enter fullscreen mode Exit fullscreen mode

5. Update CHANGELOG.md

Attach releaseNotes to file. Just one string.



await $`echo ${releaseNotes}"\n$(cat ./CHANGELOG.md)" > ./CHANGELOG.md`


Enter fullscreen mode Exit fullscreen mode

6. Update package version



await $`npm --no-git-tag-version version ${nextVersion}`


Enter fullscreen mode Exit fullscreen mode

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`


Enter fullscreen mode Exit fullscreen mode

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}`


Enter fullscreen mode Exit fullscreen mode

9. Npm publish



await $`npm publish --no-git-tag-version`


Enter fullscreen mode Exit fullscreen mode

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`


Enter fullscreen mode Exit fullscreen mode

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

GitHub logo semrel-extra / zx-semrel

`zx`-based release script as `semantic-release` alternative (PoC)

zx-semrel

Release

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

🚀 Usage

  1. Copy
  2. Tweak up, inject tokens, etc
  3. Run
curl https://raw.githubusercontent.com/semrel-extra/zx-semrel/master/release.mjs > ./release.mjs
zx ./release.mjs
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

or just run it without any edits though npx:

Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
antongolub
Anton Golub

Posted on May 23, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related