Actually you don’t need 'semantic-release' for semantic release
Tags: javascript, github, opensource, productivity
Published: 2021-05-23 12:32:10

I’m a big fan of [semantic-release](https://semantic-release.gitbook.io/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](https://github.com/qiwi/semantic-release-toolkit). I wrote [plugins](https://github.com/qiwi/semantic-release-gh-pages-plugin), [plugin-factories](https://github.com/qiwi/semantic-release-toolkit/tree/master/packages/plugin-creator) and [testing-tools](https://github.com/qiwi/semantic-release-toolkit/tree/master/packages/testing-suite) 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/qiwi/semantic-release-monorepo-hooks)
* [https://github.com/dhoulb/multi-semantic-release](https://github.com/dhoulb/multi-semantic-release)
* [https://github.com/qiwi/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](https://semantic-release.gitbook.io/semantic-release/extending/plugins-list), 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"_](https://github.com/semantic-release/npm/issues/194). In this case you can not rely on stable, convenient and tested in millions runs [semrel/npm plugin](https://github.com/semantic-release/npm), and you have to just write a pair of commands by hand with [semantic-release/exec](https://github.com/semantic-release/exec) instead:
```bash
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](https://github.com/semantic-release/semantic-release/blob/4c1c9928697569f0efa3b786a7e3da0fd01fcee9/lib/branches/index.js#L24). [_"Afraid that won't be possible"_](https://github.com/semantic-release/semantic-release/issues/1646).

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](https://github.com/dhoulb/multi-semantic-release/blob/561a8e66133d422d88008c32c479d1148876aba4/lib/execaHook.js#L5)). 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](https://github.com/google/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](https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token) or [SSH token](https://stackoverflow.com/questions/26372417/github-oauth2-token-how-to-restrict-access-to-read-a-single-private-repo) is required to push the commit.

```js
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:
```js
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](https://semver.org/) pattern:
```js
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:
```js
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:
```js
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:
```js
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:

```js
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
```js
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.
```js
await $`echo ${releaseNotes}"\n$(cat ./CHANGELOG.md)" > ./CHANGELOG.md`
```

### 6. Update package version
```js
await $`npm --no-git-tag-version version ${nextVersion}`
```

### 7. Git release.
Create commit. Create tag. Push them.
```js
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](https://docs.github.com/en/rest/reference/repos#releases).
```js
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
```js
await $`npm publish --no-git-tag-version`
```
Need several registries? NP.
```js
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](https://github.com/semrel-extra/zx-semrel/blob/master/release.mjs)
[gh release.yaml](https://github.com/semrel-extra/zx-semrel/blob/master/.github/workflows/release.yml)
[release log](https://github.com/semrel-extra/zx-semrel/runs/2649051545?check_suite_focus=true)

{% github semrel-extra/zx-semrel %}