Joshua Wood Web Developer

Release Automation With Ship.js and Keep a Changelog

I set up Ship.js on my first project today. I really like the PR-based release workflow, all automated with GitHub Actions—it’s exactly what I was looking for.

The Ship.js release process works like this:

  • Step 1 (automated): shipjs prepare calculates the next version, prepares the release on a branch, and submits a pull request.

  • Step 2 (human): You review the PR, edit if necessary, and then merge to release.

  • Step 3 (automated): shipjs trigger monitors your master branch and automatically releases to NPM (or wherever).

Releasing via PR gives you the opportunity to review each release beforehand, and see the results of your existing automated tests.

Keep a Changelog vs. Conventional Commits

Ship.js uses Conventional Commits by default, but I much prefer the more human-centric and flexible Keep a Changelog.

Conventional Commits is a specification for commit messages; it requires you to write your commit messages in a special format that is readable by machines as well as humans. It’s powerful for automation purposes (automatically calculating versions and generating changelogs, for example), but I find it restrictive.

Keep a Changelog is a radically different approach to changelogs, in that it’s something that you write intentionally for your users. This is an important distinction, because the audience for commit messages and changelogs are often different.

You should always write good (and detailed) commit messages, but they should be written for the future developers of your software, including yourself—not your end-users: for them, you should keep an equally detailed changelog.

Ship.js with Keep a Changelog

I was able to override the default behavior in the Ship.js JavaScript config file. My config calculates semantic versions from Keep a Changelog’s unreleased headings, and updates the changelog with the new release heading when bumping the version (it also supports pre-releases). I’ll probably expand/explain this in more detail at some point, but here’s the config for now:

const fs = require('fs');
const semver = require('semver')

module.exports = {
  updateChangelog: false,
  formatCommitMessage: ({ version }) => `Release v${version}`,
  formatPullRequestTitle: ({ version }) => `Release v${version}`,
  getNextVersion: ({ currentVersion, dir }) => {
    const changelog = new Changelog(`${dir}/CHANGELOG.md`)
    return changelog.nextVersion(currentVersion)
  },
  versionUpdated: ({ version, _releaseType, dir, _exec }) => {
    const parsedVersion = semver.parse(version)
    if (parsedVersion.prerelease.length) { return }

    const changelogFile = `${dir}/CHANGELOG.md`
    fs.readFile(changelogFile, 'utf8', function (err, data) {
      if (err) {
        throw(err);
      }
      const match = data.match(/## \[Unreleased\](?:\[(.*)\])?/)
      if (!match) { throw(new Error('Release heading not found in CHANGELOG.md')) }
      const result = data.replace(match[0], `## [Unreleased][${match[1] || "latest"}]\n\n## [${version}] - ${getDateString()}`)
      fs.writeFile(changelogFile, result, 'utf8', function (err) {
        if (err) { throw(err) }
      })
    })

    function getDateString() {
      const today = new Date()
      const dd = String(today.getDate()).padStart(2, '0')
      const mm = String(today.getMonth() + 1).padStart(2, '0')
      const yyyy = today.getFullYear();
      return `${yyyy}-${mm}-${dd}`
    }
  },
  afterPublish: ({ exec }) => {
    exec(`./scripts/release-cdn.sh`)
  }
}

class Changelog {
  releaseType = "patch"
  releaseTag = "latest"

  constructor(path) {
    const data = fs.readFileSync(path, 'utf8')
    const lines = data.split(/\r?\n/)
    const headings = []
    let unreleased = false
    lines.every((line) => {
      if (line.startsWith("## [Unreleased]")) {
        unreleased = true
        const tagMatch = line.match(/## \[Unreleased\]\[(.*)\]/)
        if (tagMatch) {
          this.releaseTag = tagMatch[1].trim()
        }
      } else if (line.startsWith("## ")) {
        return false
      }

      if (unreleased) {
        if (line.startsWith("### ")) {
          headings.push(line.match(/### (.*)/)[1].trim())
        }
      }

      return true
    });

    if (headings.includes("Changed")) {
      this.releaseType = "major"
    } else if (headings.includes("Added")) {
      this.releaseType = "minor"
    } else if (headings.includes("Fixed")) {
      this.releaseType = "patch"
    }
  }

  nextVersion(version) {
    const parsedVersion = semver.parse(version)

    if (this.releaseTag !== "latest") {
      if (parsedVersion.prerelease.length) {
        parsedVersion.inc("prerelease", this.releaseTag)
      } else {
        parsedVersion.inc(this.releaseType)
        parsedVersion.prerelease = [ this.releaseTag, 0 ]
        parsedVersion.format()
      }
    } else {
      parsedVersion.inc(this.releaseType)
    }

    return parsedVersion.version
  }
}

You can see this setup in action over at my honeybadger-io/honeybadger-js repo on GitHub.