Automation/Scripts

Ship It 2/3: Turning the Release Checklist Into a Script

Ship It, part two: I had a release checklist in a text file that I re-read every time and still managed to skip a step eventually. So I stopped trusting myself.

Part one of this series got the build itself reproducible. This part is about the process wrapped around that build — the release checklist I kept in a text file and re-read before every release, and still managed to skip a step from occasionally.

The Checklist I Didn't Trust Myself to Follow

It looked reasonable on paper:

1. Run tests
2. Bump version in package.json
3. Update CHANGELOG.md
4. Create git tag
5. Push tag
6. Build and push Docker image
7. Deploy

The problem was never the list itself — it was that a text file doesn't stop you from doing step 6 before step 2, or from tagging a version that doesn't match what's actually in package.json. Twice I shipped a Docker image tagged with the previous version number because I'd built it before bumping.

One Script, Same Steps, No Skipping

The fix was making the order and the dependencies between steps impossible to get wrong, by writing them into a script instead of a document a human has to follow correctly every time:

#!/usr/bin/env bash
set -euo pipefail

echo "== running tests =="
npm test

echo "== bumping version =="
npm version "${1:?usage: release.sh <patch|minor|major>}" --no-git-tag-version
VERSION=$(node -p "require('./package.json').version")

echo "== updating changelog =="
node scripts/update-changelog.mjs "$VERSION"

git add package.json package-lock.json CHANGELOG.md
git commit -m "release: v${VERSION}"
git tag "v${VERSION}"

echo "== building image =="
docker build -t "registry.example.com/app:${VERSION}" .

echo "== pushing =="
git push origin main
git push origin "v${VERSION}"
docker push "registry.example.com/app:${VERSION}"

echo "release v${VERSION} ready to deploy"

set -euo pipefail at the top means the script stops immediately on the first failure instead of plowing ahead — if tests fail, nothing gets tagged, built, or pushed. That single line has probably saved more bad releases than anything else in the script.

Reading the Version From One Place

The other deliberate choice was reading $VERSION back out of package.json right after bumping it, instead of trusting a separately-typed argument to stay in sync. Every downstream step — the git tag, the changelog entry, the Docker tag — pulls from that same single value, so there's no longer a way for the tag and the image to disagree, which is exactly the bug that started this whole cleanup.

Still a Human in the Loop, on Purpose

I deliberately left release.sh as something I run manually, with a required argument (patch/minor/major), rather than triggering it automatically on every merge to main. Releases are still a decision, not a side effect — the automation is there so that once I've made that decision, executing it correctly doesn't depend on my memory at 6pm on a Friday. The distinction matters: automating the decision of when to release would remove a judgment call I actually want to keep making deliberately, while automating the execution just removes the ways I can get that execution wrong once I've already decided.

What's Next

With a build I trust and a release process that can't skip steps, the last piece was actually getting a release running somewhere real. Part three covers self-hosting Ghost on a small VPS — the actual production target this whole series has been building toward.