diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e4f36e82e30f4..8442dc8c69ec2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 \ diff --git a/scripts/lib.sh b/scripts/lib.sh index 67922db3dd9d9..ea00c7b7cbdf3 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -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. diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000000000..83c1ea01fde4d --- /dev/null +++ b/scripts/release.sh @@ -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 ] [--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 diff --git a/scripts/release/check_commit_metadata.sh b/scripts/release/check_commit_metadata.sh new file mode 100755 index 0000000000000..94704e5b3f6e7 --- /dev/null +++ b/scripts/release/check_commit_metadata.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash + +# Usage: source ./check_commit_metadata.sh +# Usage: ./check_commit_metadata.sh +# +# 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 diff --git a/scripts/release/generate_release_notes.sh b/scripts/release/generate_release_notes.sh new file mode 100755 index 0000000000000..a966c3f768d1e --- /dev/null +++ b/scripts/release/generate_release_notes.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +# Usage: ./generate_release_notes.sh --old-version --new-version --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\` +" diff --git a/scripts/publish_release.sh b/scripts/release/publish.sh similarity index 88% rename from scripts/publish_release.sh rename to scripts/release/publish.sh index 80725cca9a931..1ae4a7ba8ecb1 100755 --- a/scripts/publish_release.sh +++ b/scripts/release/publish.sh @@ -7,7 +7,7 @@ # pipeline to do the final publish step. If you want to create a release use: # git tag -a -m "$ver" "$ver" && git push origin "$ver" # -# Usage: ./publish_release.sh [--version 1.2.3] [--dry-run] path/to/asset1 path/to/asset2 ... +# Usage: ./publish.sh [--version 1.2.3] [--dry-run] path/to/asset1 path/to/asset2 ... # # The supplied images must already be pushed to the registry or this will fail. # Also, the source images cannot be in a different registry than the target @@ -27,7 +27,7 @@ set -euo pipefail # shellcheck source=scripts/lib.sh -source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +source "$(dirname "${BASH_SOURCE[0]}")/../lib.sh" if [[ "${CI:-}" == "" ]]; then error "This script must be run in CI" @@ -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/release/check_commit_metadata.sh +source "$SCRIPT_DIR/release/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"