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
Next Next commit
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.
  • Loading branch information
mafredri committed Dec 9, 2022
commit 19a39b477626606e7b285c24e104c064d7d665e2
108 changes: 108 additions & 0 deletions scripts/check_commit_metadata.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about placing all these scripts in the separate release directory?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we have enough release related scripts to put them there, I'd still like to keep /scripts/release.sh (because used by humans) but the rest can go in /scripts/release/*.sh.


# Usage: source ./check_commit_tags.sh <revision range>
# Usage: ./check_commit_tags.sh <revision range>
#
# Example: ./check_commit_tags.sh v0.13.1..971e3678
#
# When sourced, this script will populate the COMMIT_METADATA_* variables
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applicable only to squash & merge - Do you think that we may need another script executed in CI to check if the title of PR is consistent?

Copy link
Member Author

@mafredri mafredri Dec 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, what do you mean by Applicable only to squash & merge? I guess we could make it clearer this script is only intended for the commit log on the main branch.

Yes, we'll add an action/lint for PR title 👍🏻 (follow up PR)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we'll add an action/lint for PR title 👍🏻 (follow up PR)

👍

Applicable only to squash & merge?

I thought that Create a merge commit option is also enabled for this repo, but it seems to be off. If it's on, it might be more challenging to validate also commits than only the PR title.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, yeah we'll only validate PR titles, I made the assumption when writing that we'll only squash merge.

Obviously owners/admins could bypass this by pushing straight to main. 🙄

# with the commit metadata for each commit in the revision range.
#
# Because this script does some expensive lookups via the GitHub API, it's
# 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"

range=${1:-}

if [[ -z $range ]]; then
error "No revision range specified"
fi

# 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

mapfile -t commits < <(git log --no-merges --pretty=format:"%h %s" "$range")

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

# Store the commit title for later use.
title=${parts[*]:1}
title=${title%$'\n'}
COMMIT_METADATA_TITLE[$commit_sha]=$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 ]]; then
COMMIT_METADATA_CATEGORY[$commit_sha]=$breaking_category
COMMIT_METADATA_BREAKING=1
continue
fi

# Get the labels for the PR associated with this commit.
mapfile -t labels < <(gh api -H "Accept: application/vnd.github+json" "/repos/coder/coder/commits/${commit_sha}/pulls" -q '.[].labels[].name')

if [[ " ${labels[*]} " = *" ${breaking_label} "* ]]; then
COMMIT_METADATA_CATEGORY[$commit_sha]=$breaking_category
COMMIT_METADATA_BREAKING=1
continue
fi

if [[ $commit_prefix =~ $prefix_pattern ]]; then
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I still have a feeling that we should switch to Python or mage at this point :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I won't get behind Python, but I'm indifferent to mage, so it's not me you have to convince. 😉

commit_prefix=${BASH_REMATCH[1]}
fi
case $commit_prefix in
feat | fix)
COMMIT_METADATA_CATEGORY[$commit_sha]=$commit_prefix
;;
*)
COMMIT_METADATA_CATEGORY[$commit_sha]=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/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/check_commit_metadata.sh
source "$SCRIPT_DIR/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

https://github.com/coder/coder/compare/${old_version}...${new_version}

## Container image:

- \`docker pull $image_tag\`
"
9 changes: 9 additions & 0 deletions scripts/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ log() {
# error prints an error message and returns an error exit code.
error() {
log "ERROR: $*"
if issourced; then
return 1
fi
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you put a comment here explaining why this is necessary?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I'll just remove it. It's not really necessary if we always want to exit on error. I had the scripts set up in a different way before.

exit 1
}

Expand All @@ -131,6 +134,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
19 changes: 6 additions & 13 deletions scripts/publish_release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,23 +101,16 @@ old_tag="$(git describe --abbrev=0 HEAD^1)"

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

# Craft the release notes.
changelog="$(git log --no-merges --pretty=format:"- %h %s" "$changelog_range")"
image_tag="$(execrelative ./image_tag.sh --version "$version")"
release_notes="
## Changelog

$changelog
# shellcheck source=scripts/check_commit_metadata.sh
source "$SCRIPT_DIR/check_commit_metadata.sh" "$old_tag..$new_ref"

## Container Image
- \`docker pull $image_tag\`

"
# Craft the release notes.
release_notes="$(execrelative ./generate_release_notes.sh --old-version "$old_tag" --new-version "$new_tag" --ref "$new_ref")"

release_notes_file="$(mktemp)"
echo "$release_notes" >"$release_notes_file"
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/check_commit_metadata.sh
source "$SCRIPT_DIR/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 ./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..."
echo git tag -a "$new_version" -m "$new_version" "$ref"
log "Pushing tag to origin..."
echo git push -u origin "$new_version"
fi