Skip to content

Commit 08c8cdd

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 08c8cdd

1 file changed

Lines changed: 181 additions & 0 deletions

File tree

dev-bin/release.sh

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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 ! gh auth status >/dev/null 2>&1; then
94+
die "gh is not authenticated"
95+
fi
96+
97+
if git show-ref --verify --quiet "refs/tags/$tag"; then
98+
local_tag_commit=$(git rev-parse --verify "refs/tags/$tag^{commit}") ||
99+
die "tag $tag does not resolve to a commit"
100+
if [ "$local_tag_commit" != "$release_commit" ]; then
101+
die "tag $tag exists locally at a different commit"
102+
fi
103+
fi
104+
105+
remote_tag_commit=$(
106+
git ls-remote origin "refs/tags/$tag" "refs/tags/$tag^{}" |
107+
awk '
108+
/\^\{\}$/ { peeled = $1; next }
109+
{ direct = $1 }
110+
END { print peeled ? peeled : direct }
111+
'
112+
)
113+
if [ -n "$remote_tag_commit" ] && [ "$remote_tag_commit" != "$release_commit" ]; then
114+
die "tag $tag exists on origin at a different commit"
115+
fi
116+
117+
release_response=$(
118+
gh api --include --silent "repos/{owner}/{repo}/releases/tags/$tag" 2>/dev/null || true
119+
)
120+
release_http_status=$(printf '%s\n' "$release_response" | awk 'NR == 1 { print $2 }')
121+
case "$release_http_status" in
122+
200) die "release $tag already exists" ;;
123+
404) ;;
124+
*) die "could not verify whether release $tag exists" ;;
125+
esac
126+
127+
echo "Running release checks..."
128+
if [ "$(go env GOHOSTOS)" != "linux" ]; then
129+
die "linux/386 tests require running the release script on Linux"
130+
fi
131+
case "$(go env GOHOSTARCH)" in
132+
386 | amd64) ;;
133+
*)
134+
die "linux/386 tests require an x86 host or emulator"
135+
;;
136+
esac
137+
golangci-lint fmt
138+
go mod tidy
139+
go mod verify
140+
go test ./...
141+
go test -race ./...
142+
CGO_ENABLED=0 GOOS=linux GOARCH=386 go test ./...
143+
golangci-lint run
144+
145+
if [ -n "$(git status --porcelain)" ]; then
146+
echo "Release checks modified the working directory:" >&2
147+
git status --short >&2
148+
git diff >&2
149+
exit 1
150+
fi
151+
152+
if [ "$(git rev-parse --verify HEAD)" != "$release_commit" ]; then
153+
die "HEAD changed during release checks"
154+
fi
155+
156+
echo
157+
echo "Version: $version"
158+
echo
159+
echo "Release notes:"
160+
printf '%s\n' "$notes"
161+
echo
162+
163+
read -r -p "Create $tag from $current_branch and push to origin? [y/N] " should_release
164+
if [[ ! $should_release =~ ^[Yy]$ ]]; then
165+
echo "Aborting"
166+
exit 1
167+
fi
168+
169+
git push --atomic origin \
170+
"$release_commit:refs/heads/$current_branch" \
171+
"$release_commit:refs/tags/$tag"
172+
173+
notes_file=$(mktemp "${TMPDIR:-/tmp}/maxminddb-release-notes.XXXXXX")
174+
trap 'rm -f -- "$notes_file"' EXIT
175+
printf '%s\n' "$notes" >"$notes_file"
176+
177+
gh release create \
178+
--verify-tag \
179+
--title "$version" \
180+
--notes-file "$notes_file" \
181+
"$tag"

0 commit comments

Comments
 (0)