Skip to content

Commit 19a39b4

Browse files
committed
feat: Add release.sh script and detect breaking changes
This commit introduces three new scripts: - `release.sh` To be run by a user on their local machine to preview and create a new release (tag + push) - `check_commit_metadata.sh` For e.g. detecting breaking changes - `genereate_release_notes.sh` To display the generated release notes, used for previews and in `publish_release.sh` The `release.sh` script can be run without arguments, and it will automatically determine if we're to do a patch or minor release. A minor release can be forced via `--minor` flag. Breaking changes can be annotated either via commit/merge title prefix (`feat!:`, `feat(api)!:`), or by adding the `release/breaking` label to the PR that was merged (on GitHub). Related #5233 This work will be followed up by changes to move the tag creation from `release.sh` to CI workflows, the `release.sh` will then become a lightweight wrapper to run the workflow.
1 parent c6ae151 commit 19a39b4

File tree

5 files changed

+356
-13
lines changed

5 files changed

+356
-13
lines changed

scripts/check_commit_metadata.sh

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env bash
2+
3+
# Usage: source ./check_commit_tags.sh <revision range>
4+
# Usage: ./check_commit_tags.sh <revision range>
5+
#
6+
# Example: ./check_commit_tags.sh v0.13.1..971e3678
7+
#
8+
# When sourced, this script will populate the COMMIT_METADATA_* variables
9+
# with the commit metadata for each commit in the revision range.
10+
#
11+
# Because this script does some expensive lookups via the GitHub API, it's
12+
# results will be cached in the environment and restored if this script is
13+
# sourced a second time with the same arguments.
14+
15+
set -euo pipefail
16+
# shellcheck source=scripts/lib.sh
17+
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
18+
19+
range=${1:-}
20+
21+
if [[ -z $range ]]; then
22+
error "No revision range specified"
23+
fi
24+
25+
# Check dependencies.
26+
dependencies gh
27+
28+
COMMIT_METADATA_BREAKING=0
29+
declare -A COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY
30+
31+
main() {
32+
# Match a commit prefix pattern, e.g. feat: or feat(site):.
33+
prefix_pattern="^([a-z]+)(\([a-z]*\))?:"
34+
35+
# If a commit contains this title prefix or the source PR contains the
36+
# label, patch releases will not be allowed.
37+
# This regex matches both `feat!:` and `feat(site)!:`.
38+
breaking_title="^[a-z]+(\([a-z]*\))?!:"
39+
breaking_label=release/breaking
40+
breaking_category=breaking
41+
42+
mapfile -t commits < <(git log --no-merges --pretty=format:"%h %s" "$range")
43+
44+
for commit in "${commits[@]}"; do
45+
mapfile -d ' ' -t parts <<<"$commit"
46+
commit_sha=${parts[0]}
47+
commit_prefix=${parts[1]}
48+
49+
# Store the commit title for later use.
50+
title=${parts[*]:1}
51+
title=${title%$'\n'}
52+
COMMIT_METADATA_TITLE[$commit_sha]=$title
53+
54+
# First, check the title for breaking changes. This avoids doing a
55+
# GH API request if there's a match.
56+
if [[ $commit_prefix =~ $breaking_title ]]; then
57+
COMMIT_METADATA_CATEGORY[$commit_sha]=$breaking_category
58+
COMMIT_METADATA_BREAKING=1
59+
continue
60+
fi
61+
62+
# Get the labels for the PR associated with this commit.
63+
mapfile -t labels < <(gh api -H "Accept: application/vnd.github+json" "/repos/coder/coder/commits/${commit_sha}/pulls" -q '.[].labels[].name')
64+
65+
if [[ " ${labels[*]} " = *" ${breaking_label} "* ]]; then
66+
COMMIT_METADATA_CATEGORY[$commit_sha]=$breaking_category
67+
COMMIT_METADATA_BREAKING=1
68+
continue
69+
fi
70+
71+
if [[ $commit_prefix =~ $prefix_pattern ]]; then
72+
commit_prefix=${BASH_REMATCH[1]}
73+
fi
74+
case $commit_prefix in
75+
feat | fix)
76+
COMMIT_METADATA_CATEGORY[$commit_sha]=$commit_prefix
77+
;;
78+
*)
79+
COMMIT_METADATA_CATEGORY[$commit_sha]=other
80+
;;
81+
esac
82+
done
83+
}
84+
85+
declare_print_commit_metadata() {
86+
declare -p COMMIT_METADATA_BREAKING COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY
87+
}
88+
89+
export_commit_metadata() {
90+
_COMMIT_METADATA_CACHE="${range}:$(declare_print_commit_metadata)"
91+
export _COMMIT_METADATA_CACHE COMMIT_METADATA_BREAKING COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY
92+
}
93+
94+
# _COMMIT_METADATA_CACHE is used to cache the results of this script in
95+
# the environment because bash arrays are not passed on to subscripts.
96+
if [[ ${_COMMIT_METADATA_CACHE:-} == "${range}:"* ]]; then
97+
eval "${_COMMIT_METADATA_CACHE#*:}"
98+
else
99+
main
100+
fi
101+
102+
export_commit_metadata
103+
104+
# Make it easier to debug this script by printing the associative array
105+
# when it's not sourced.
106+
if ! issourced; then
107+
declare_print_commit_metadata
108+
fi

scripts/generate_release_notes.sh

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env bash
2+
3+
# Usage: ./generate_release_notes.sh --old-version <old version> --new-version <new version> --ref <ref>
4+
#
5+
# Example: ./generate_release_notes.sh --old-version v0.13.0 --new-version v0.13.1 --ref 1e6b244c
6+
#
7+
# This script generates release notes for the given version. It will generate
8+
# release notes for all commits between the old version and the new version.
9+
#
10+
# Ref must be set to the commit that the new version will be tagget at. This
11+
# is used to determine the commits that are included in the release. If the
12+
# commit is already tagged, ref can be set to the tag name.
13+
14+
set -euo pipefail
15+
# shellcheck source=scripts/lib.sh
16+
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
17+
18+
old_version=
19+
new_version=
20+
ref=
21+
22+
args="$(getopt -o '' -l old-version:,new-version:,ref: -- "$@")"
23+
eval set -- "$args"
24+
while true; do
25+
case "$1" in
26+
--old-version)
27+
old_version="$2"
28+
shift 2
29+
;;
30+
--new-version)
31+
new_version="$2"
32+
shift 2
33+
;;
34+
--ref)
35+
ref="$2"
36+
shift 2
37+
;;
38+
--)
39+
shift
40+
break
41+
;;
42+
*)
43+
error "Unrecognized option: $1"
44+
;;
45+
esac
46+
done
47+
48+
# Check dependencies.
49+
dependencies gh sort
50+
51+
if [[ -z $old_version ]]; then
52+
error "No old version specified"
53+
fi
54+
if [[ -z $new_version ]]; then
55+
error "No new version specified"
56+
fi
57+
if [[ -z $ref ]]; then
58+
error "No ref specified"
59+
fi
60+
61+
# shellcheck source=scripts/check_commit_metadata.sh
62+
source "$SCRIPT_DIR/check_commit_metadata.sh" "${old_version}..${ref}"
63+
64+
# Sort commits by title prefix, then by date, only return sha at the end.
65+
mapfile -t commits < <(git log --no-merges --pretty=format:"%ct %h %s" "${old_version}..${ref}" | sort -k3,3 -k1,1n | cut -d' ' -f2)
66+
67+
breaking_changelog=
68+
feat_changelog=
69+
fix_changelog=
70+
other_changelog=
71+
72+
for commit in "${commits[@]}"; do
73+
line="- $commit ${COMMIT_METADATA_TITLE[$commit]}\n"
74+
75+
case "${COMMIT_METADATA_CATEGORY[$commit]}" in
76+
breaking)
77+
breaking_changelog+="$line"
78+
;;
79+
feat)
80+
feat_changelog+="$line"
81+
;;
82+
fix)
83+
fix_changelog+="$line"
84+
;;
85+
*)
86+
other_changelog+="$line"
87+
;;
88+
esac
89+
done
90+
91+
changelog="$(
92+
if ((${#breaking_changelog} > 0)); then
93+
echo -e "### BREAKING CHANGES\n"
94+
echo -e "$breaking_changelog"
95+
fi
96+
if ((${#feat_changelog} > 0)); then
97+
echo -e "### Features\n"
98+
echo -e "$feat_changelog"
99+
fi
100+
if ((${#fix_changelog} > 0)); then
101+
echo -e "### Bug fixes\n"
102+
echo -e "$fix_changelog"
103+
fi
104+
if ((${#other_changelog} > 0)); then
105+
echo -e "### Other changes\n"
106+
echo -e "$other_changelog"
107+
fi
108+
)"
109+
110+
image_tag="$(execrelative ./image_tag.sh --version "$new_version")"
111+
112+
echo -e "## Changelog:
113+
114+
$changelog
115+
116+
https://github.com/coder/coder/compare/${old_version}...${new_version}
117+
118+
## Container image:
119+
120+
- \`docker pull $image_tag\`
121+
"

scripts/lib.sh

+9
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ log() {
123123
# error prints an error message and returns an error exit code.
124124
error() {
125125
log "ERROR: $*"
126+
if issourced; then
127+
return 1
128+
fi
126129
exit 1
127130
}
128131

@@ -131,6 +134,12 @@ isdarwin() {
131134
[[ "${OSTYPE:-darwin}" == *darwin* ]]
132135
}
133136

137+
# issourced returns true if the script that sourced this script is being
138+
# sourced by another.
139+
issourced() {
140+
[[ "${BASH_SOURCE[1]}" != "$0" ]]
141+
}
142+
134143
# We don't need to check dependencies more than once per script, but some
135144
# scripts call other scripts that also `source lib.sh`, so we set an environment
136145
# variable after successfully checking dependencies once.

scripts/publish_release.sh

+6-13
Original file line numberDiff line numberDiff line change
@@ -101,23 +101,16 @@ old_tag="$(git describe --abbrev=0 HEAD^1)"
101101

102102
# For dry-run builds we want to use the SHA instead of the tag, because the new
103103
# tag probably doesn't exist.
104-
changelog_range="$old_tag..$new_tag"
104+
new_ref="$new_tag"
105105
if [[ "$dry_run" == 1 ]]; then
106-
changelog_range="$old_tag..$(git rev-parse --short HEAD)"
106+
new_ref="$(git rev-parse --short HEAD)"
107107
fi
108108

109-
# Craft the release notes.
110-
changelog="$(git log --no-merges --pretty=format:"- %h %s" "$changelog_range")"
111-
image_tag="$(execrelative ./image_tag.sh --version "$version")"
112-
release_notes="
113-
## Changelog
114-
115-
$changelog
109+
# shellcheck source=scripts/check_commit_metadata.sh
110+
source "$SCRIPT_DIR/check_commit_metadata.sh" "$old_tag..$new_ref"
116111

117-
## Container Image
118-
- \`docker pull $image_tag\`
119-
120-
"
112+
# Craft the release notes.
113+
release_notes="$(execrelative ./generate_release_notes.sh --old-version "$old_tag" --new-version "$new_tag" --ref "$new_ref")"
121114

122115
release_notes_file="$(mktemp)"
123116
echo "$release_notes" >"$release_notes_file"

scripts/release.sh

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/usr/bin/env bash
2+
3+
# This script should be called to create a new release.
4+
#
5+
# When run, this script will display the new version number and optionally a
6+
# preview of the release notes. The new version will be selected automatically
7+
# based on if the release contains breaking changes or not. If the release
8+
# contains breaking changes, a new minor version will be created. Otherwise, a
9+
# new patch version will be created.
10+
#
11+
# Set --ref if you need to specify a specific commit that the new version will
12+
# be tagged at, otherwise the latest commit will be used.
13+
#
14+
# Set --minor to force a minor version bump, even when there are no breaking
15+
# changes.
16+
#
17+
# To mark a release as containing breaking changes, the commit title should
18+
# either contain a known prefix with an exclamation mark ("feat!:",
19+
# "feat(api)!:") or the PR that was merged can be tagged with the
20+
# "release/breaking" label.
21+
#
22+
# Usage: ./release.sh [--ref <ref>] [--minor]
23+
24+
set -euo pipefail
25+
# shellcheck source=scripts/lib.sh
26+
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
27+
cdroot
28+
29+
ref=
30+
minor=0
31+
32+
args="$(getopt -o n -l ref:,minor -- "$@")"
33+
eval set -- "$args"
34+
while true; do
35+
case "$1" in
36+
--ref)
37+
ref="$2"
38+
shift 2
39+
;;
40+
--minor)
41+
minor=1
42+
shift
43+
;;
44+
--)
45+
shift
46+
break
47+
;;
48+
*)
49+
error "Unrecognized option: $1"
50+
;;
51+
esac
52+
done
53+
54+
# Check dependencies.
55+
dependencies gh sort
56+
57+
# Make sure the repository is up-to-date before generating release notes.
58+
log "Fetching main and tags from origin..."
59+
git fetch --quiet --tags origin main
60+
61+
# Resolve to the latest ref on origin/main unless otherwise specified.
62+
ref=$(git rev-parse --short "${ref:-origin/main}")
63+
64+
# Make sure that we're running the latest release script.
65+
if [[ -n $(git diff --name-status origin/main -- ./scripts/release.sh) ]]; then
66+
error "Release script is out-of-date. Please check out the latest version and try again."
67+
fi
68+
69+
# Check the current version tag from GitHub (by number) using the API to
70+
# ensure no local tags are considered.
71+
mapfile -t versions < <(gh api -H "Accept: application/vnd.github+json" /repos/coder/coder/git/refs/tags -q '.[].ref | split("/") | .[2]' | grep '^v' | sort -r -V)
72+
old_version=${versions[0]}
73+
74+
log "Checking commit metadata for changes since $old_version..."
75+
# shellcheck source=scripts/check_commit_metadata.sh
76+
source "$SCRIPT_DIR/check_commit_metadata.sh" "$old_version..$ref"
77+
78+
mapfile -d . -t version_parts <<<"$old_version"
79+
if [[ $minor == 1 ]] || [[ $COMMIT_METADATA_BREAKING == 1 ]]; then
80+
if [[ $COMMIT_METADATA_BREAKING == 1 ]]; then
81+
log "Breaking change detected, incrementing minor version..."
82+
else
83+
log "Forcing minor version bump..."
84+
fi
85+
version_parts[1]=$((version_parts[1] + 1))
86+
version_parts[2]=0
87+
else
88+
log "No breaking changes detected, incrementing patch version..."
89+
version_parts[2]=$((version_parts[2] + 1))
90+
fi
91+
new_version="${version_parts[0]}.${version_parts[1]}.${version_parts[2]}"
92+
93+
log "Old version: ${old_version}"
94+
log "New version: ${new_version}"
95+
96+
release_notes="$(execrelative ./generate_release_notes.sh --old-version "$old_version" --new-version "$new_version" --ref "$ref")"
97+
98+
echo
99+
read -p "Preview release notes? (y/n) " -n 1 -r show_reply
100+
echo
101+
if [[ $show_reply =~ ^[Yy]$ ]]; then
102+
echo -e "$release_notes\n"
103+
fi
104+
105+
read -p "Create release? (y/n) " -n 1 -r create
106+
echo
107+
if [[ $create =~ ^[Yy]$ ]]; then
108+
log "Tagging commit $ref as $new_version..."
109+
echo git tag -a "$new_version" -m "$new_version" "$ref"
110+
log "Pushing tag to origin..."
111+
echo git push -u origin "$new_version"
112+
fi

0 commit comments

Comments
 (0)