Skip to content

feat: Add release.sh script and detect breaking changes #5366

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Dec 15, 2022
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ jobs:

- name: Publish release
run: |
./scripts/publish_release.sh \
./scripts/release/publish.sh \
${{ (github.event.inputs.dry_run || github.event.inputs.snapshot) && '--dry-run' }} \
./build/*_installer.exe \
./build/*.zip \
Expand Down
6 changes: 6 additions & 0 deletions scripts/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ isdarwin() {
[[ "${OSTYPE:-darwin}" == *darwin* ]]
}

# issourced returns true if the script that sourced this script is being
# sourced by another.
issourced() {
[[ "${BASH_SOURCE[1]}" != "$0" ]]
}

# We don't need to check dependencies more than once per script, but some
# scripts call other scripts that also `source lib.sh`, so we set an environment
# variable after successfully checking dependencies once.
Expand Down
112 changes: 112 additions & 0 deletions scripts/release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env bash

# This script should be called to create a new release.
#
# When run, this script will display the new version number and optionally a
# preview of the release notes. The new version will be selected automatically
# based on if the release contains breaking changes or not. If the release
# contains breaking changes, a new minor version will be created. Otherwise, a
# new patch version will be created.
#
# Set --ref if you need to specify a specific commit that the new version will
# be tagged at, otherwise the latest commit will be used.
#
# Set --minor to force a minor version bump, even when there are no breaking
# changes.
#
# To mark a release as containing breaking changes, the commit title should
# either contain a known prefix with an exclamation mark ("feat!:",
# "feat(api)!:") or the PR that was merged can be tagged with the
# "release/breaking" label.
#
# Usage: ./release.sh [--ref <ref>] [--minor]

set -euo pipefail
# shellcheck source=scripts/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
cdroot

ref=
minor=0

args="$(getopt -o n -l ref:,minor -- "$@")"
eval set -- "$args"
while true; do
case "$1" in
--ref)
ref="$2"
shift 2
;;
--minor)
minor=1
shift
;;
--)
shift
break
;;
*)
error "Unrecognized option: $1"
;;
esac
done

# Check dependencies.
dependencies gh sort

# Make sure the repository is up-to-date before generating release notes.
log "Fetching main and tags from origin..."
git fetch --quiet --tags origin main

# Resolve to the latest ref on origin/main unless otherwise specified.
ref=$(git rev-parse --short "${ref:-origin/main}")

# Make sure that we're running the latest release script.
if [[ -n $(git diff --name-status origin/main -- ./scripts/release.sh) ]]; then
error "Release script is out-of-date. Please check out the latest version and try again."
fi

# Check the current version tag from GitHub (by number) using the API to
# ensure no local tags are considered.
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)
old_version=${versions[0]}

log "Checking commit metadata for changes since $old_version..."
# shellcheck source=scripts/release/check_commit_metadata.sh
source "$SCRIPT_DIR/release/check_commit_metadata.sh" "$old_version" "$ref"

mapfile -d . -t version_parts <<<"$old_version"
if [[ $minor == 1 ]] || [[ $COMMIT_METADATA_BREAKING == 1 ]]; then
if [[ $COMMIT_METADATA_BREAKING == 1 ]]; then
log "Breaking change detected, incrementing minor version..."
else
log "Forcing minor version bump..."
fi
version_parts[1]=$((version_parts[1] + 1))
version_parts[2]=0
else
log "No breaking changes detected, incrementing patch version..."
version_parts[2]=$((version_parts[2] + 1))
fi
new_version="${version_parts[0]}.${version_parts[1]}.${version_parts[2]}"

log "Old version: ${old_version}"
log "New version: ${new_version}"

release_notes="$(execrelative ./release/generate_release_notes.sh --old-version "$old_version" --new-version "$new_version" --ref "$ref")"

echo
read -p "Preview release notes? (y/n) " -n 1 -r show_reply
echo
if [[ $show_reply =~ ^[Yy]$ ]]; then
echo -e "$release_notes\n"
fi

read -p "Create release? (y/n) " -n 1 -r create
echo
if [[ $create =~ ^[Yy]$ ]]; then
log "Tagging commit $ref as $new_version..."
git tag -a "$new_version" -m "$new_version" "$ref"
log "Pushing tag to origin..."
git push -u origin "$new_version"
fi
142 changes: 142 additions & 0 deletions scripts/release/check_commit_metadata.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env bash

# Usage: source ./check_commit_metadata.sh <from revision> <to revision>
# Usage: ./check_commit_metadata.sh <from revision> <to revision>
#
# Example: ./check_commit_metadata.sh v0.13.1 971e3678
#
# When sourced, this script will populate the COMMIT_METADATA_* variables
# with the commit metadata for each commit in the revision range.
#
# Because this script does some expensive lookups via the GitHub API, its
# results will be cached in the environment and restored if this script is
# sourced a second time with the same arguments.

set -euo pipefail
# shellcheck source=scripts/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/../lib.sh"

from_ref=${1:-}
to_ref=${2:-}

if [[ -z $from_ref ]]; then
error "No from_ref specified"
fi
if [[ -z $to_ref ]]; then
error "No to_ref specified"
fi

range="$from_ref..$to_ref"

# Check dependencies.
dependencies gh

COMMIT_METADATA_BREAKING=0
declare -A COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY

main() {
# Match a commit prefix pattern, e.g. feat: or feat(site):.
prefix_pattern="^([a-z]+)(\([a-z]*\))?:"

# If a commit contains this title prefix or the source PR contains the
# label, patch releases will not be allowed.
# This regex matches both `feat!:` and `feat(site)!:`.
breaking_title="^[a-z]+(\([a-z]*\))?!:"
breaking_label=release/breaking
breaking_category=breaking

# Get abbreviated and full commit hashes and titles for each commit.
mapfile -t commits < <(git log --no-merges --pretty=format:"%h %H %s" "$range")

# If this is a tag, use rev-list to find the commit it points to.
from_commit=$(git rev-list -n 1 "$from_ref")
# Get the committer date of the commit so that we can list PRs merged.
from_commit_date=$(git show --no-patch --date=short --format=%cd "$from_commit")

# Get the labels for all PRs merged since the last release, this is
# inexact based on date, so a few PRs part of the previous release may
# be included.
#
# Example output:
#
# 27386d49d08455b6f8fbf2c18f38244d03fda892 label:security
# d9f2aaf3b430d8b6f3d5f24032ed6357adaab1f1
# fd54512858c906e66f04b0744d8715c2e0de97e6 label:stale label:enhancement
mapfile -t pr_labels_raw < <(
gh pr list \
--base main \
--state merged \
--limit 10000 \
--search "merged:>=$from_commit_date" \
--json mergeCommit,labels \
--jq '.[] | .mergeCommit.oid + " " + (["label:" + .labels[].name] | join(" "))'
)
declare -A labels
for entry in "${pr_labels_raw[@]}"; do
commit_sha_long=${entry%% *}
all_labels=${entry#* }
labels[$commit_sha_long]=$all_labels
done

for commit in "${commits[@]}"; do
mapfile -d ' ' -t parts <<<"$commit"
commit_sha_short=${parts[0]}
commit_sha_long=${parts[1]}
commit_prefix=${parts[2]}

# Safety-check, guarantee all commits had their metadata fetched.
if [[ ! -v labels[$commit_sha_long] ]]; then
error "Metadata missing for commit $commit_sha_short"
fi

# Store the commit title for later use.
title=${parts[*]:2}
title=${title%$'\n'}
COMMIT_METADATA_TITLE[$commit_sha_short]=$title

# First, check the title for breaking changes. This avoids doing a
# GH API request if there's a match.
if [[ $commit_prefix =~ $breaking_title ]] || [[ ${labels[$commit_sha_long]} = *"label:$breaking_label"* ]]; then
COMMIT_METADATA_CATEGORY[$commit_sha_short]=$breaking_category
COMMIT_METADATA_BREAKING=1
continue
fi

if [[ $commit_prefix =~ $prefix_pattern ]]; then
commit_prefix=${BASH_REMATCH[1]}
fi
case $commit_prefix in
feat | fix)
COMMIT_METADATA_CATEGORY[$commit_sha_short]=$commit_prefix
;;
*)
COMMIT_METADATA_CATEGORY[$commit_sha_short]=other
;;
esac
done
}

declare_print_commit_metadata() {
declare -p COMMIT_METADATA_BREAKING COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY
}

export_commit_metadata() {
_COMMIT_METADATA_CACHE="${range}:$(declare_print_commit_metadata)"
export _COMMIT_METADATA_CACHE COMMIT_METADATA_BREAKING COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY
}

# _COMMIT_METADATA_CACHE is used to cache the results of this script in
# the environment because bash arrays are not passed on to subscripts.
if [[ ${_COMMIT_METADATA_CACHE:-} == "${range}:"* ]]; then
eval "${_COMMIT_METADATA_CACHE#*:}"
else
main
fi

export_commit_metadata

# Make it easier to debug this script by printing the associative array
# when it's not sourced.
if ! issourced; then
declare_print_commit_metadata
fi
121 changes: 121 additions & 0 deletions scripts/release/generate_release_notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env bash

# Usage: ./generate_release_notes.sh --old-version <old version> --new-version <new version> --ref <ref>
#
# Example: ./generate_release_notes.sh --old-version v0.13.0 --new-version v0.13.1 --ref 1e6b244c
#
# This script generates release notes for the given version. It will generate
# release notes for all commits between the old version and the new version.
#
# Ref must be set to the commit that the new version will be tagget at. This
# is used to determine the commits that are included in the release. If the
# commit is already tagged, ref can be set to the tag name.

set -euo pipefail
# shellcheck source=scripts/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/../lib.sh"

old_version=
new_version=
ref=

args="$(getopt -o '' -l old-version:,new-version:,ref: -- "$@")"
eval set -- "$args"
while true; do
case "$1" in
--old-version)
old_version="$2"
shift 2
;;
--new-version)
new_version="$2"
shift 2
;;
--ref)
ref="$2"
shift 2
;;
--)
shift
break
;;
*)
error "Unrecognized option: $1"
;;
esac
done

# Check dependencies.
dependencies gh sort

if [[ -z $old_version ]]; then
error "No old version specified"
fi
if [[ -z $new_version ]]; then
error "No new version specified"
fi
if [[ -z $ref ]]; then
error "No ref specified"
fi

# shellcheck source=scripts/release/check_commit_metadata.sh
source "$SCRIPT_DIR/release/check_commit_metadata.sh" "${old_version}" "${ref}"

# Sort commits by title prefix, then by date, only return sha at the end.
mapfile -t commits < <(git log --no-merges --pretty=format:"%ct %h %s" "${old_version}..${ref}" | sort -k3,3 -k1,1n | cut -d' ' -f2)

breaking_changelog=
feat_changelog=
fix_changelog=
other_changelog=

for commit in "${commits[@]}"; do
line="- $commit ${COMMIT_METADATA_TITLE[$commit]}\n"

case "${COMMIT_METADATA_CATEGORY[$commit]}" in
breaking)
breaking_changelog+="$line"
;;
feat)
feat_changelog+="$line"
;;
fix)
fix_changelog+="$line"
;;
*)
other_changelog+="$line"
;;
esac
done

changelog="$(
if ((${#breaking_changelog} > 0)); then
echo -e "### BREAKING CHANGES\n"
echo -e "$breaking_changelog"
fi
if ((${#feat_changelog} > 0)); then
echo -e "### Features\n"
echo -e "$feat_changelog"
fi
if ((${#fix_changelog} > 0)); then
echo -e "### Bug fixes\n"
echo -e "$fix_changelog"
fi
if ((${#other_changelog} > 0)); then
echo -e "### Other changes\n"
echo -e "$other_changelog"
fi
)"

image_tag="$(execrelative ./image_tag.sh --version "$new_version")"

echo -e "## Changelog

$changelog

Compare: [\`${old_version}...${new_version}\`](https://github.com/coder/coder/compare/${old_version}...${new_version})

## Container image

- \`docker pull $image_tag\`
"
Loading