Skip to content

Commit e96fdbe

Browse files
authored
feat: Add release.sh script and detect breaking changes (coder#5366)
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 coder#5233
1 parent 4bc420d commit e96fdbe

File tree

6 files changed

+390
-16
lines changed

6 files changed

+390
-16
lines changed

.github/workflows/release.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ jobs:
157157

158158
- name: Publish release
159159
run: |
160-
./scripts/publish_release.sh \
160+
./scripts/release/publish.sh \
161161
${{ (github.event.inputs.dry_run || github.event.inputs.snapshot) && '--dry-run' }} \
162162
./build/*_installer.exe \
163163
./build/*.zip \

scripts/lib.sh

+6
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ isdarwin() {
131131
[[ "${OSTYPE:-darwin}" == *darwin* ]]
132132
}
133133

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

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/release/check_commit_metadata.sh
76+
source "$SCRIPT_DIR/release/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 ./release/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+
git tag -a "$new_version" -m "$new_version" "$ref"
110+
log "Pushing tag to origin..."
111+
git push -u origin "$new_version"
112+
fi
+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env bash
2+
3+
# Usage: source ./check_commit_metadata.sh <from revision> <to revision>
4+
# Usage: ./check_commit_metadata.sh <from revision> <to revision>
5+
#
6+
# Example: ./check_commit_metadata.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, its
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+
from_ref=${1:-}
20+
to_ref=${2:-}
21+
22+
if [[ -z $from_ref ]]; then
23+
error "No from_ref specified"
24+
fi
25+
if [[ -z $to_ref ]]; then
26+
error "No to_ref specified"
27+
fi
28+
29+
range="$from_ref..$to_ref"
30+
31+
# Check dependencies.
32+
dependencies gh
33+
34+
COMMIT_METADATA_BREAKING=0
35+
declare -A COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY
36+
37+
main() {
38+
# Match a commit prefix pattern, e.g. feat: or feat(site):.
39+
prefix_pattern="^([a-z]+)(\([a-z]*\))?:"
40+
41+
# If a commit contains this title prefix or the source PR contains the
42+
# label, patch releases will not be allowed.
43+
# This regex matches both `feat!:` and `feat(site)!:`.
44+
breaking_title="^[a-z]+(\([a-z]*\))?!:"
45+
breaking_label=release/breaking
46+
breaking_category=breaking
47+
48+
# Get abbreviated and full commit hashes and titles for each commit.
49+
mapfile -t commits < <(git log --no-merges --pretty=format:"%h %H %s" "$range")
50+
51+
# If this is a tag, use rev-list to find the commit it points to.
52+
from_commit=$(git rev-list -n 1 "$from_ref")
53+
# Get the committer date of the commit so that we can list PRs merged.
54+
from_commit_date=$(git show --no-patch --date=short --format=%cd "$from_commit")
55+
56+
# Get the labels for all PRs merged since the last release, this is
57+
# inexact based on date, so a few PRs part of the previous release may
58+
# be included.
59+
#
60+
# Example output:
61+
#
62+
# 27386d49d08455b6f8fbf2c18f38244d03fda892 label:security
63+
# d9f2aaf3b430d8b6f3d5f24032ed6357adaab1f1
64+
# fd54512858c906e66f04b0744d8715c2e0de97e6 label:stale label:enhancement
65+
mapfile -t pr_labels_raw < <(
66+
gh pr list \
67+
--base main \
68+
--state merged \
69+
--limit 10000 \
70+
--search "merged:>=$from_commit_date" \
71+
--json mergeCommit,labels \
72+
--jq '.[] | .mergeCommit.oid + " " + (["label:" + .labels[].name] | join(" "))'
73+
)
74+
declare -A labels
75+
for entry in "${pr_labels_raw[@]}"; do
76+
commit_sha_long=${entry%% *}
77+
all_labels=${entry#* }
78+
labels[$commit_sha_long]=$all_labels
79+
done
80+
81+
for commit in "${commits[@]}"; do
82+
mapfile -d ' ' -t parts <<<"$commit"
83+
commit_sha_short=${parts[0]}
84+
commit_sha_long=${parts[1]}
85+
commit_prefix=${parts[2]}
86+
87+
# Safety-check, guarantee all commits had their metadata fetched.
88+
if [[ ! -v labels[$commit_sha_long] ]]; then
89+
error "Metadata missing for commit $commit_sha_short"
90+
fi
91+
92+
# Store the commit title for later use.
93+
title=${parts[*]:2}
94+
title=${title%$'\n'}
95+
COMMIT_METADATA_TITLE[$commit_sha_short]=$title
96+
97+
# First, check the title for breaking changes. This avoids doing a
98+
# GH API request if there's a match.
99+
if [[ $commit_prefix =~ $breaking_title ]] || [[ ${labels[$commit_sha_long]} = *"label:$breaking_label"* ]]; then
100+
COMMIT_METADATA_CATEGORY[$commit_sha_short]=$breaking_category
101+
COMMIT_METADATA_BREAKING=1
102+
continue
103+
fi
104+
105+
if [[ $commit_prefix =~ $prefix_pattern ]]; then
106+
commit_prefix=${BASH_REMATCH[1]}
107+
fi
108+
case $commit_prefix in
109+
feat | fix)
110+
COMMIT_METADATA_CATEGORY[$commit_sha_short]=$commit_prefix
111+
;;
112+
*)
113+
COMMIT_METADATA_CATEGORY[$commit_sha_short]=other
114+
;;
115+
esac
116+
done
117+
}
118+
119+
declare_print_commit_metadata() {
120+
declare -p COMMIT_METADATA_BREAKING COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY
121+
}
122+
123+
export_commit_metadata() {
124+
_COMMIT_METADATA_CACHE="${range}:$(declare_print_commit_metadata)"
125+
export _COMMIT_METADATA_CACHE COMMIT_METADATA_BREAKING COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY
126+
}
127+
128+
# _COMMIT_METADATA_CACHE is used to cache the results of this script in
129+
# the environment because bash arrays are not passed on to subscripts.
130+
if [[ ${_COMMIT_METADATA_CACHE:-} == "${range}:"* ]]; then
131+
eval "${_COMMIT_METADATA_CACHE#*:}"
132+
else
133+
main
134+
fi
135+
136+
export_commit_metadata
137+
138+
# Make it easier to debug this script by printing the associative array
139+
# when it's not sourced.
140+
if ! issourced; then
141+
declare_print_commit_metadata
142+
fi
+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/release/check_commit_metadata.sh
62+
source "$SCRIPT_DIR/release/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+
Compare: [\`${old_version}...${new_version}\`](https://github.com/coder/coder/compare/${old_version}...${new_version})
117+
118+
## Container image
119+
120+
- \`docker pull $image_tag\`
121+
"

0 commit comments

Comments
 (0)