diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3b5c1638d..9cae92750 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,11 +1,21 @@ # GitHub release workflow. -name: release +name: New Release on: - push: - tags: - - "v*" workflow_dispatch: inputs: + increment: + description: Preferred version increment (release script may promote e.g. patch to minor depending on changes). + type: choice + required: true + default: patch + options: + - patch + - minor + - major + draft: + description: Create a draft release (for manually editing release notes before publishing). + type: boolean + required: true snapshot: description: Force a dev version to be generated, implies dry_run. type: boolean @@ -25,6 +35,7 @@ permissions: env: CODER_RELEASE: ${{ github.event.inputs.snapshot && 'false' || 'true' }} + DRY_RUN: ${{ (github.event.inputs.dry_run || github.event.inputs.snapshot) && 'true' || 'false' }} jobs: release: @@ -100,6 +111,36 @@ jobs: AC_CERTIFICATE_PASSWORD: ${{ secrets.AC_CERTIFICATE_PASSWORD }} AC_APIKEY_P8_BASE64: ${{ secrets.AC_APIKEY_P8_BASE64 }} + - name: Create release tag and release notes + run: | + ref=HEAD + old_version="$(git describe --abbrev=0 "$ref^1")" + + if [[ $DRY_RUN == true ]]; then + # Allow dry-run of branches to pass. + export CODER_IGNORE_MISSING_COMMIT_METADATA=1 + fi + + # Cache commit metadata. + . ./scripts/release/check_commit_metadata.sh "$old_version" "$ref" + + # Create new release tag. + version="$( + ./scripts/release/increment_version_tag.sh \ + ${{ (github.event.inputs.dry_run || github.event.inputs.snapshot) && '--dry-run' }} \ + --ref "$ref" \ + --${{ github.event.inputs.increment }} + )" + + # Generate notes. + release_notes="$(./scripts/release/generate_release_notes.sh --old-version "$old_version" --new-version "$version" --ref "$ref")" + echo 'CODER_RELEASE_NOTES<> $GITHUB_ENV + echo "$release_notes" >> $GITHUB_ENV + echo 'RN_EOF' >> $GITHUB_ENV + + - name: Echo release notes + run: echo "$CODER_RELEASE_NOTES" + - name: Build binaries run: | set -euo pipefail @@ -158,7 +199,9 @@ jobs: - name: Publish release run: | ./scripts/release/publish.sh \ + ${{ github.event.inputs.draft && '--draft' }} \ ${{ (github.event.inputs.dry_run || github.event.inputs.snapshot) && '--dry-run' }} \ + --release-notes "$CODER_RELEASE_NOTES" \ ./build/*_installer.exe \ ./build/*.zip \ ./build/*.tar.gz \ @@ -195,6 +238,7 @@ jobs: with: name: release-artifacts path: | + ./build/*_installer.exe ./build/*.zip ./build/*.tar.gz ./build/*.tgz diff --git a/scripts/release.sh b/scripts/release.sh index 83c1ea01f..4df75d238 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,44 +1,80 @@ #!/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 +usage() { + cat <] [--draft] [--dry-run] [--ref ] [--major | --minor | --patch] + +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. Likewise for --major. By default a patch version will be created. + +Set --dry-run to run the release workflow in CI as a dry-run (no release will +be created). + +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. + +To test changes to this script, you can set --branch , which will +run the release workflow in CI as a dry-run and use the latest commit on the +specified branch as the release commit. This will also set --dry-run. +EOH +} + +branch=main +draft=0 +dry_run=0 ref= -minor=0 +increment= -args="$(getopt -o n -l ref:,minor -- "$@")" +args="$(getopt -o h -l branch:,draft,dry-run,help,ref:,major,minor,patch -- "$@")" eval set -- "$args" while true; do case "$1" in + --branch) + branch="$2" + log "Using branch $branch, implies DRYRUN and CODER_IGNORE_MISSING_COMMIT_METADATA." + dry_run=1 + export CODER_IGNORE_MISSING_COMMIT_METADATA=1 + shift 2 + ;; + --draft) + draft=1 + shift + ;; + --dry-run) + dry_run=1 + shift + ;; + -h | --help) + usage + exit 0 + ;; --ref) ref="$2" shift 2 ;; - --minor) - minor=1 + --major | --minor | --patch) + if [[ -n $increment ]]; then + error "Cannot specify multiple version increments." + fi + increment=${1#--} shift ;; --) @@ -54,15 +90,20 @@ done # Check dependencies. dependencies gh sort +if [[ -z $increment ]]; then + # Default to patch versions. + increment="patch" +fi + # 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 +log "Fetching $branch and tags from origin..." +git fetch --quiet --tags origin "$branch" # Resolve to the latest ref on origin/main unless otherwise specified. -ref=$(git rev-parse --short "${ref:-origin/main}") +ref=$(git rev-parse --short "${ref:-origin/$branch}") # Make sure that we're running the latest release script. -if [[ -n $(git diff --name-status origin/main -- ./scripts/release.sh) ]]; then +if [[ -n $(git diff --name-status origin/"$branch" -- ./scripts/release.sh) ]]; then error "Release script is out-of-date. Please check out the latest version and try again." fi @@ -71,28 +112,10 @@ fi 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}" - +new_version="$(execrelative ./release/increment_version_tag.sh --dry-run --ref "$ref" --"$increment")" release_notes="$(execrelative ./release/generate_release_notes.sh --old-version "$old_version" --new-version "$new_version" --ref "$ref")" echo @@ -102,11 +125,31 @@ if [[ $show_reply =~ ^[Yy]$ ]]; then echo -e "$release_notes\n" fi -read -p "Create release? (y/n) " -n 1 -r create +create_message="Create release" +if ((draft)); then + create_message="Create draft release" +fi +if ((dry_run)); then + create_message+=" (DRYRUN)" +fi +read -p "$create_message? (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" +if ! [[ $create =~ ^[Yy]$ ]]; then + exit 0 +fi + +args=() +if ((draft)); then + args+=(-F draft=true) fi +if ((dry_run)); then + args+=(-F dry_run=true) +fi + +gh workflow run release.yaml \ + --ref "$branch" \ + -F increment="$increment" \ + -F snapshot=false \ + "${args[@]}" + +log "Release process started, you can watch the release via: gh run watch --exit-status " diff --git a/scripts/release/check_commit_metadata.sh b/scripts/release/check_commit_metadata.sh index 76e781dd2..bfdd3b33b 100755 --- a/scripts/release/check_commit_metadata.sh +++ b/scripts/release/check_commit_metadata.sh @@ -34,6 +34,10 @@ dependencies gh COMMIT_METADATA_BREAKING=0 declare -A COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY +# This environment variable can be set to 1 to ignore missing commit metadata, +# useful for dry-runs. +ignore_missing_metadata=${CODER_IGNORE_MISSING_COMMIT_METADATA:-0} + main() { # Match a commit prefix pattern, e.g. feat: or feat(site):. prefix_pattern="^([a-z]+)(\([a-z]*\))?:" @@ -87,9 +91,11 @@ main() { 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" + if [[ $ignore_missing_metadata != 1 ]]; then + # 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 fi # Store the commit title for later use. @@ -99,11 +105,11 @@ main() { # 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 + 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 - elif [[ ${labels[$commit_sha_long]} = *"label:$security_label"* ]]; then + elif [[ ${labels[$commit_sha_long]:-} = *"label:$security_label"* ]]; then COMMIT_METADATA_CATEGORY[$commit_sha_short]=$security_label continue fi @@ -137,6 +143,9 @@ export_commit_metadata() { if [[ ${_COMMIT_METADATA_CACHE:-} == "${range}:"* ]]; then eval "${_COMMIT_METADATA_CACHE#*:}" else + if [[ $ignore_missing_metadata == 1 ]]; then + log "WARNING: Ignoring missing commit metadata, breaking changes may be missed." + fi main fi diff --git a/scripts/release/generate_release_notes.sh b/scripts/release/generate_release_notes.sh index df6cfd98b..56e1508ac 100755 --- a/scripts/release/generate_release_notes.sh +++ b/scripts/release/generate_release_notes.sh @@ -59,10 +59,10 @@ if [[ -z $ref ]]; then fi # shellcheck source=scripts/release/check_commit_metadata.sh -source "$SCRIPT_DIR/release/check_commit_metadata.sh" "${old_version}" "${ref}" +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) +mapfile -t commits < <(git log --no-merges --pretty=format:"%ct %h %s" "$old_version..$ref" | sort -k3,3 -k1,1n | cut -d' ' -f2) # From: https://github.com/commitizen/conventional-commit-types # NOTE(mafredri): These need to be supported in check_commit_metadata.sh as well. @@ -140,7 +140,7 @@ 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}) +Compare: [\`$old_version...$new_version\`](https://github.com/coder/coder/compare/$old_version...$new_version) ## Container Image diff --git a/scripts/release/increment_version_tag.sh b/scripts/release/increment_version_tag.sh new file mode 100755 index 000000000..85ac1d14e --- /dev/null +++ b/scripts/release/increment_version_tag.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash + +set -euo pipefail +# shellcheck source=scripts/lib.sh +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/lib.sh" +cdroot + +usage() { + cat <] <--major | --minor | --patch> + +This script should be called to tag a new release. It will take the suggested +increment (major, minor, patch) and optionally promote e.g. patch -> minor if +there are breaking changes between the previous version and the given --ref +(or HEAD). + +This script will create a git tag, so it should only be run in CI (or via +--dry-run). +EOH +} + +dry_run=0 +ref=HEAD +increment= + +args="$(getopt -o h -l dry-run,help,ref:,major,minor,patch -- "$@")" +eval set -- "$args" +while true; do + case "$1" in + --dry-run) + dry_run=1 + shift + ;; + --ref) + ref="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + --major | --minor | --patch) + if [[ -n $increment ]]; then + error "Cannot specify multiple version increments." + fi + increment=${1#--} + shift + ;; + --) + shift + break + ;; + *) + error "Unrecognized option: $1" + ;; + esac +done + +# Check dependencies. +dependencies git + +if [[ -z $increment ]]; then + error "No version increment provided." +fi + +if [[ $dry_run != 1 ]] && [[ ${CI:-} == "" ]]; then + error "This script must be run in CI or with --dry-run." +fi + +old_version="$(git describe --abbrev=0 "$ref^1")" +cur_tag="$(git describe --abbrev=0 "$ref")" +if [[ $old_version != "$cur_tag" ]]; then + if ! ((dry_run)); then + error "Ref \"$ref\" is already tagged with a release ($cur_tag)." + fi + echo "$new_version" + +fi +ref=$(git rev-parse --short "$ref") + +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" + +if ((COMMIT_METADATA_BREAKING == 1)); then + prev_increment=$increment + if [[ $increment == patch ]]; then + increment=minor + fi + if [[ $prev_increment != "$increment" ]]; then + log "Breaking change detected, changing version increment from \"$prev_increment\" to \"$increment\"." + else + log "Breaking change detected, provided increment is sufficient, using \"$increment\" increment." + fi +else + log "No breaking changes detected, using \"$increment\" increment." +fi + +mapfile -d . -t version_parts <<<"${old_version#v}" +case "$increment" in +patch) + version_parts[2]=$((version_parts[2] + 1)) + ;; +minor) + version_parts[1]=$((version_parts[1] + 1)) + version_parts[2]=0 + ;; +major) + version_parts[0]=$((version_parts[0] + 1)) + version_parts[1]=0 + version_parts[2]=0 + ;; +*) + error "Unrecognized version increment." + ;; +esac + +new_version="v${version_parts[0]}.${version_parts[1]}.${version_parts[2]}" + +log "Old version: $old_version" +log "New version: $new_version" +maybedryrun "$dry_run" git tag -a "$new_version" -m "Release $new_version" "$ref" +maybedryrun "$dry_run" git push --quiet origin "$new_version" + +echo "$new_version" diff --git a/scripts/release/publish.sh b/scripts/release/publish.sh index 8b592212b..863dc07e3 100755 --- a/scripts/release/publish.sh +++ b/scripts/release/publish.sh @@ -34,9 +34,11 @@ if [[ "${CI:-}" == "" ]]; then fi version="" +release_notes="" +draft=0 dry_run=0 -args="$(getopt -o "" -l version:,dry-run -- "$@")" +args="$(getopt -o "" -l version:,release-notes:,draft,dry-run -- "$@")" eval set -- "$args" while true; do case "$1" in @@ -44,6 +46,14 @@ while true; do version="$2" shift 2 ;; + --release-notes) + release_notes="$2" + shift 2 + ;; + --draft) + draft=1 + shift + ;; --dry-run) dry_run=1 shift @@ -67,6 +77,10 @@ if [[ "$version" == "" ]]; then version="$(execrelative ./version.sh)" fi +if [[ -z $release_notes ]]; then + error "No release notes specified, use --release-notes." +fi + # realpath-ify all input files so we can cdroot below. files=() for f in "$@"; do @@ -96,22 +110,6 @@ if [[ "$(git describe --always)" != "$new_tag" ]]; then log "The provided version does not match the current git tag, but --dry-run was supplied so continuing..." fi -# This returns the tag before the current tag. -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. -new_ref="$new_tag" -if [[ "$dry_run" == 1 ]]; then - new_ref="$(git rev-parse --short HEAD)" -fi - -# shellcheck source=scripts/release/check_commit_metadata.sh -source "$SCRIPT_DIR/release/check_commit_metadata.sh" "$old_tag" "$new_ref" - -# Craft the release notes. -release_notes="$(execrelative ./release/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" @@ -127,7 +125,7 @@ pushd "$temp_dir" sha256sum ./* | sed -e 's/\.\///' - >"coder_${version}_checksums.txt" popd -log "--- Creating release $new_tag" +log "--- Publishing release $new_tag on GitHub" log log "Description:" echo "$release_notes" | sed -e 's/^/\t/' - 1>&2 @@ -139,11 +137,17 @@ popd log log +args=() +if ((draft)); then + args+=(--draft) +fi + # We pipe `true` into `gh` so that it never tries to be interactive. true | maybedryrun "$dry_run" gh release create \ --title "$new_tag" \ --notes-file "$release_notes_file" \ + "${args[@]}" \ "$new_tag" \ "$temp_dir"/*