Add release automation script

Add script/release to update the version, run tests, create and push
the release commit and tag, and draft GitHub release notes.

Usage:
  script/release --dry-run 0.7.0
  script/release 0.7.0
  script/release 0.7.0 "Teal Otter"
This commit is contained in:
Ryan Caloras 2026-08-22 22:30:07 -04:00
parent 66e913cd9d
commit 3281b825cb
3 changed files with 293 additions and 2 deletions

View file

@ -13,7 +13,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Install Bats
run: apk add --no-cache bats
- name: Install test dependencies
run: apk add --no-cache bats git
- name: Run tests
run: bats test

222
script/release Executable file
View file

@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: script/release [--dry-run] [--yes] VERSION ["Color Animal"]
Prepare a release, push it, and create draft release notes on GitHub.
Options:
--dry-run Validate the release and run tests without changing anything.
--yes Skip confirmation prompts.
-h, --help Show this help.
If no title is supplied, an unused Color Animal title is generated.
EOF
}
die() {
echo "release: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
}
confirm() {
local answer
if [[ "$assume_yes" == true ]]; then
return 0
fi
read -r -p "$1 [y/N] " answer || return 1
[[ "$answer" == "y" || "$answer" == "Y" || "$answer" == "yes" || "$answer" == "YES" ]]
}
title_exists() {
printf '%s\n' "$existing_titles" | grep -Fxi -- "$1" >/dev/null 2>&1
}
generate_title() {
local colors=(
Amber Aqua Black Blue Bronze Brown Coral Crimson Cyan Emerald
Gold Gray Green Indigo Ivory Lavender Lime Magenta Maroon Navy
Ochre Olive Orange Peach Pink Plum Purple Red Rose Ruby
Sapphire Scarlet Silver Tan Teal Turquoise Violet White Yellow
)
local animals=(
Alpaca Badger Bear Beaver Bison Boa Bobcat Buffalo Camel Capybara
Cat Cheetah Cobra Condor Cougar Coyote Crane Crow Deer Dolphin
Donkey Eagle Elephant Falcon Ferret Finch Fox Gecko Giraffe Goat
Goldfish Goose Heron Hippo Horse Ibex Iguana Jaguar Koala Lemur
Leopard Lion Llama Lobster Lynx Mantis Mole Moose Narwhal Octopus
Otter Owl Oyster Panda Panther Parrot Pelican Penguin Pigeon Puma
Rabbit Raven Rhino Seal Shark Sheep Sloth Sparrow Squid Swan Tiger
Toad Toucan Turtle Walrus Whale Wolf Wombat Yak Zebra
)
local candidate
local attempts=0
while (( attempts < 200 )); do
candidate="${colors[RANDOM % ${#colors[@]}]} ${animals[RANDOM % ${#animals[@]}]}"
if ! title_exists "$candidate"; then
printf '%s\n' "$candidate"
return 0
fi
attempts=$((attempts + 1))
done
die "could not generate an unused release title"
}
dry_run=false
assume_yes=false
while (( $# > 0 )); do
case "$1" in
--dry-run)
dry_run=true
shift
;;
--yes)
assume_yes=true
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
die "unknown option: $1"
;;
*)
break
;;
esac
done
(( $# >= 1 && $# <= 2 )) || {
usage >&2
exit 1
}
version=$1
requested_title=${2:-}
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || \
die "version must look like 0.7.0 or 0.7.0-rc.1"
require_command git
require_command gh
require_command bats
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || die "not inside a Git repository"
cd "$repo_root"
[[ -f bash-preexec.sh ]] || die "run this from the bash-preexec repository"
[[ -z "$(git status --porcelain)" ]] || die "working tree is not clean"
branch=$(git symbolic-ref --quiet --short HEAD) || die "releases cannot be made from a detached HEAD"
default_branch=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
default_branch=${default_branch#origin/}
default_branch=${default_branch:-master}
[[ "$branch" == "$default_branch" ]] || \
die "switch to the default branch ($default_branch); currently on $branch"
echo "Fetching the latest branch and tags from origin..."
git fetch --quiet origin "$branch" --tags
[[ "$(git rev-parse HEAD)" == "$(git rev-parse "refs/remotes/origin/$branch")" ]] || \
die "$branch must exactly match origin/$branch before releasing"
if git rev-parse --quiet --verify "refs/tags/$version" >/dev/null; then
die "tag already exists: $version"
fi
if gh release view "$version" >/dev/null 2>&1; then
die "GitHub release already exists: $version"
fi
current_versions=$(sed -n 's/^# V\(.*\)$/\1/p' bash-preexec.sh)
[[ -n "$current_versions" && "$current_versions" != *$'\n'* ]] || \
die "expected exactly one '# V...' version marker in bash-preexec.sh"
current_version=$current_versions
[[ "$current_version" != "$version" ]] || die "bash-preexec.sh is already at version $version"
existing_titles=$(gh release list --limit 1000 --json name --jq '.[].name')
if [[ -n "$requested_title" ]]; then
[[ "$requested_title" =~ ^[[:alpha:]]+[[:space:]][[:alpha:]]+$ ]] || \
die "release title must have the form 'Color Animal'"
title=$requested_title
title_exists "$title" && die "release title is already in use: $title"
else
title=$(generate_title)
fi
echo
echo "Release plan"
echo " Version: $current_version -> $version"
echo " Title: $title"
echo " Branch: $branch"
echo " Notes: GitHub-generated draft"
echo
if [[ "$dry_run" == true ]]; then
echo "Running bats test..."
bats test
echo
echo "Dry run complete; no files, commits, tags, or releases were changed."
exit 0
fi
confirm "Prepare the release commit and tag?" || die "cancelled"
sed -i.bak "s/^# V.*$/# V${version}/" bash-preexec.sh
rm -f bash-preexec.sh.bak
echo "Running bats test..."
if ! bats test; then
sed -i.bak "s/^# V.*$/# V${current_version}/" bash-preexec.sh
rm -f bash-preexec.sh.bak
die "tests failed; restored bash-preexec.sh"
fi
git diff --check
git add -- bash-preexec.sh
git commit -m "Preparing to tag v${version}"
git tag "$version"
if ! confirm "Push $branch and tag $version, then create the draft GitHub release?"; then
echo
echo "Release commit and tag were created locally but not pushed."
echo "To finish later:"
echo " git push --atomic origin $branch refs/tags/$version"
echo " gh release create $version --verify-tag --title \"$title\" --generate-notes --draft"
exit 0
fi
git push --atomic origin "$branch" "refs/tags/$version"
echo
echo "Creating draft GitHub release..."
if release_url=$(gh release create "$version" \
--verify-tag \
--title "$title" \
--generate-notes \
--draft); then
echo "Draft release created: $release_url"
else
echo "The commit and tag were pushed, but the draft release could not be created." >&2
echo "Retry with:" >&2
echo " gh release create $version --verify-tag --title \"$title\" --generate-notes --draft" >&2
exit 1
fi

69
test/release-script.bats Normal file
View file

@ -0,0 +1,69 @@
#!/usr/bin/env bats
setup() {
release_script="$BATS_TEST_DIRNAME/../script/release"
}
make_release_repo() {
release_repo="$BATS_TEST_TMPDIR/release-repo"
release_origin="$BATS_TEST_TMPDIR/release-origin.git"
fake_bin="$BATS_TEST_TMPDIR/bin"
mkdir -p "$release_repo/script" "$fake_bin"
cp "$release_script" "$release_repo/script/release"
printf '%s\n' '# V0.6.0' > "$release_repo/bash-preexec.sh"
git init -q "$release_repo"
git -C "$release_repo" symbolic-ref HEAD refs/heads/master
git -C "$release_repo" config user.name 'Release Test'
git -C "$release_repo" config user.email 'release-test@example.com'
git -C "$release_repo" add bash-preexec.sh script/release
git -C "$release_repo" commit -qm 'Initial commit'
git init -q --bare "$release_origin"
git -C "$release_repo" remote add origin "$release_origin"
git -C "$release_repo" push -qu origin master
printf '%s\n' \
'#!/bin/sh' \
'if [ "$1" = release ] && [ "$2" = view ]; then exit 1; fi' \
'if [ "$1" = release ] && [ "$2" = list ]; then printf "%s\\n" "Black Toad"; exit 0; fi' \
'exit 2' > "$fake_bin/gh"
printf '%s\n' '#!/bin/sh' 'exit 0' > "$fake_bin/bats"
chmod +x "$fake_bin/gh" "$fake_bin/bats"
}
@test "release script has valid Bash syntax" {
run bash -n "$release_script"
[ "$status" -eq 0 ]
}
@test "release script documents its command-line interface" {
run "$release_script" --help
[ "$status" -eq 0 ]
[[ "$output" == *'Usage: script/release'* ]]
[[ "$output" == *'--dry-run'* ]]
}
@test "release script rejects an invalid version" {
run "$release_script" not-a-version
[ "$status" -eq 1 ]
[[ "$output" == *'version must look like'* ]]
}
@test "release dry run validates a unique title without changing the repository" {
make_release_repo
cd "$release_repo"
run env PATH="$fake_bin:$PATH" script/release --dry-run 0.7.0 "Teal Otter"
[ "$status" -eq 0 ]
[[ "$output" == *'Version: 0.6.0 -> 0.7.0'* ]]
[[ "$output" == *'Title: Teal Otter'* ]]
[[ "$output" == *'Dry run complete'* ]]
[ "$(sed -n 's/^# V//p' bash-preexec.sh)" = '0.6.0' ]
[ -z "$(git status --porcelain)" ]
}