Skip to content

Commit d5bf9a7

Browse files
committed
Add release script
Validate release metadata, repository state, module files, tests, and lint before creating a GitHub release from the current branch.
1 parent 376a253 commit d5bf9a7

1 file changed

Lines changed: 158 additions & 0 deletions

File tree

dev-bin/release.sh

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
cd "$(git rev-parse --show-toplevel)"
6+
7+
die() {
8+
echo "Error: $*" >&2
9+
exit 1
10+
}
11+
12+
require_command() {
13+
if ! command -v "$1" >/dev/null 2>&1; then
14+
die "$1 is not installed or not in PATH"
15+
fi
16+
}
17+
18+
for command in gh git go golangci-lint; do
19+
require_command "$command"
20+
done
21+
22+
current_branch=$(git branch --show-current)
23+
if [ -z "$current_branch" ]; then
24+
die "releases cannot be created from a detached HEAD"
25+
fi
26+
27+
echo "Fetching from origin..."
28+
git fetch origin
29+
git submodule update --init --recursive
30+
31+
if ! git merge-base --is-ancestor origin/main HEAD; then
32+
die "the current branch does not contain the latest origin/main"
33+
fi
34+
35+
if [ -n "$(git status --porcelain)" ]; then
36+
die "the working directory is not clean"
37+
fi
38+
39+
release_commit=$(git rev-parse --verify HEAD)
40+
41+
release_heading=$(awk '/^## / { print; exit }' CHANGELOG.md)
42+
if [ "$release_heading" = "## Unreleased" ]; then
43+
die "replace the Unreleased heading with a version and release date"
44+
fi
45+
46+
semver_number='(0|[1-9][0-9]*)'
47+
semver_identifier='(0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)'
48+
heading_pattern="^## $semver_number\.$semver_number\.$semver_number(-$semver_identifier(\.$semver_identifier)*)? - [0-9]{4}-[0-9]{2}-[0-9]{2}$"
49+
if [ -z "$release_heading" ] || [[ ! $release_heading =~ $heading_pattern ]]; then
50+
die "CHANGELOG.md does not start with a release heading such as ## 2.6.0 - 2026-08-08"
51+
fi
52+
53+
heading_regex='^##[[:space:]]([^[:space:]]+)[[:space:]]-[[:space:]]([0-9]{4}-[0-9]{2}-[0-9]{2})$'
54+
if [[ ! $release_heading =~ $heading_regex ]]; then
55+
die "could not parse release heading: $release_heading"
56+
fi
57+
58+
version=${BASH_REMATCH[1]}
59+
release_date=${BASH_REMATCH[2]}
60+
today=$(date +%Y-%m-%d)
61+
tag="v$version"
62+
63+
if [ "$release_date" != "$today" ]; then
64+
die "release date $release_date is not today ($today)"
65+
fi
66+
67+
notes=$(
68+
awk -v heading="$release_heading" '
69+
$0 == heading { found = 1; next }
70+
found && /^## / { exit }
71+
found { print }
72+
' CHANGELOG.md | sed '/./,$!d'
73+
)
74+
if [ -z "$notes" ]; then
75+
die "the $version changelog section has no release notes"
76+
fi
77+
78+
module_path=$(go list -m -f '{{.Path}}')
79+
major=${version%%.*}
80+
case "$major" in
81+
0 | 1)
82+
if [[ $module_path =~ /v[0-9]+$ ]]; then
83+
die "tag $tag does not match module path $module_path"
84+
fi
85+
;;
86+
*)
87+
if [[ $module_path != */v"$major" ]]; then
88+
die "tag $tag does not match module path $module_path"
89+
fi
90+
;;
91+
esac
92+
93+
if git show-ref --verify --quiet "refs/tags/$tag"; then
94+
die "tag $tag already exists locally"
95+
fi
96+
if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then
97+
die "tag $tag already exists on origin"
98+
fi
99+
100+
if ! gh auth status >/dev/null 2>&1; then
101+
die "gh is not authenticated"
102+
fi
103+
104+
echo "Running release checks..."
105+
if [ "$(go env GOHOSTOS)" != "linux" ]; then
106+
die "linux/386 tests require running the release script on Linux"
107+
fi
108+
case "$(go env GOHOSTARCH)" in
109+
386 | amd64) ;;
110+
*)
111+
die "linux/386 tests require an x86 host or emulator"
112+
;;
113+
esac
114+
golangci-lint fmt
115+
go mod tidy
116+
go mod verify
117+
go test ./...
118+
go test -race ./...
119+
CGO_ENABLED=0 GOOS=linux GOARCH=386 go test ./...
120+
golangci-lint run
121+
122+
if [ -n "$(git status --porcelain)" ]; then
123+
echo "Release checks modified the working directory:" >&2
124+
git status --short >&2
125+
git diff >&2
126+
exit 1
127+
fi
128+
129+
if [ "$(git rev-parse --verify HEAD)" != "$release_commit" ]; then
130+
die "HEAD changed during release checks"
131+
fi
132+
133+
echo
134+
echo "Version: $version"
135+
echo
136+
echo "Release notes:"
137+
printf '%s\n' "$notes"
138+
echo
139+
140+
read -r -p "Create $tag from $current_branch and push to origin? [y/N] " should_release
141+
if [[ ! $should_release =~ ^[Yy]$ ]]; then
142+
echo "Aborting"
143+
exit 1
144+
fi
145+
146+
git push --atomic origin \
147+
"$release_commit:refs/heads/$current_branch" \
148+
"$release_commit:refs/tags/$tag"
149+
150+
notes_file=$(mktemp "${TMPDIR:-/tmp}/maxminddb-release-notes.XXXXXX")
151+
trap 'rm -f -- "$notes_file"' EXIT
152+
printf '%s\n' "$notes" >"$notes_file"
153+
154+
gh release create \
155+
--verify-tag \
156+
--title "$version" \
157+
--notes-file "$notes_file" \
158+
"$tag"

0 commit comments

Comments
 (0)