Tagging Scripts

Creating tags in a release script, with the checks that stop a bad version reaching a remote.

Creating a tag looks like a one-liner, and it is — until a release script runs it with an empty argument and quietly tags whatever HEAD happens to be. The useful script is mostly validation.

The minimum

#!/usr/bin/env bash
# create-tag.sh
#
# Usage: create-tag.sh <version> [message]

set -euo pipefail

version="${1:-}"
message="${2:-}"

if [ -z "$version" ]; then
  echo "Usage: $0 <version> [message]" >&2
  exit 1
fi

# v1.4.0, not 1.4.0 or V1.4.0 -- tools like git describe assume the v
if ! printf '%s' "$version" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
  echo "Version must look like v1.4.0" >&2
  exit 1
fi

# never silently move an existing tag
if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then
  echo "Tag $version already exists; refusing to overwrite it." >&2
  exit 1
fi

# refuse to tag a working tree with uncommitted changes
if [ -n "$(git status --porcelain)" ]; then
  echo "Working tree is not clean. Commit or stash first." >&2
  exit 1
fi

git tag -a "$version" -m "${message:-Release $version}"
echo "Created $version at $(git rev-parse --short HEAD)"

Annotated, not lightweight

git tag <name> creates a lightweight tag: a reference and nothing else, with no author, no date and no message. git tag -a records all three.

Use -a in a script even when the message is a default. Plain git describe skips lightweight tags, so a build that derives its version from it without --tags will fail outright on a repository tagged that way.

Deriving the version

Hard-coding v1.4.0 in a script means the script is wrong the moment you cut the next release. Derive it instead:

# bump the patch component of the latest tag
latest=$(git describe --tags --abbrev=0)
version="v$(printf '%s' "${latest#v}" | awk -F. '{printf "%d.%d.%d", $1, $2, $3+1}')"

Signing

git tag -s asks GPG to sign the tag, which is what lets a consumer verify a release came from you:

git tag -s v1.4.0 -m "Release 1.4.0"

It needs a configured GPG key, and it will fail in CI without one — so either provide the key as a secret or make signing conditional on being able to.

The order that matters

Creating the tag does not move the branch, so the usual sequence is: commit, tag the commit, push the branch, push the tag. Pushing the tag before the commit it points at gives you a tag on the remote that refers to nothing. See Pushing Tags Scripts.