From b82a7826194adb2371fcb3943891a444483d2531 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 24 Apr 2024 22:43:11 +0300 Subject: [PATCH] chore(scripts): implement mainline and stable release channels (#13048) Fixes #12458 --- .github/workflows/release.yaml | 62 ++++-- scripts/release.sh | 218 +++++++++++++++++++--- scripts/release/check_commit_metadata.sh | 217 ++++++++++++++++----- scripts/release/generate_release_notes.sh | 107 +++++++---- scripts/release/publish.sh | 21 ++- scripts/release/tag_version.sh | 46 +++-- scripts/version.sh | 22 ++- 7 files changed, 542 insertions(+), 151 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 99ffb29b32..26493d144b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,11 +1,16 @@ # GitHub release workflow. name: Release on: - push: - tags: - - "v*" workflow_dispatch: inputs: + release_channel: + type: choice + description: Release channel + options: + - mainline + - stable + release_notes: + description: Release notes for the publishing the release. This is required to create a release. dry_run: description: Perform a dry-run release (devel). Note that ref must be an annotated tag when run without dry-run. type: boolean @@ -28,6 +33,8 @@ env: # https://github.blog/changelog/2022-06-10-github-actions-inputs-unified-across-manual-and-reusable-workflows/ CODER_RELEASE: ${{ !inputs.dry_run }} CODER_DRY_RUN: ${{ inputs.dry_run }} + CODER_RELEASE_CHANNEL: ${{ inputs.release_channel }} + CODER_RELEASE_NOTES: ${{ inputs.release_notes }} jobs: release: @@ -62,21 +69,45 @@ jobs: echo "CODER_FORCE_VERSION=$version" >> $GITHUB_ENV echo "$version" - - name: Create release notes - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # We always have to set this since there might be commits on - # main that didn't have a PR. - CODER_IGNORE_MISSING_COMMIT_METADATA: "1" + # Verify that all expectations for a release are met. + - name: Verify release input + if: ${{ !inputs.dry_run }} + run: | + set -euo pipefail + + if [[ "${GITHUB_REF}" != "refs/tags/v"* ]]; then + echo "Ref must be a semver tag when creating a release, did you use scripts/release.sh?" + exit 1 + fi + + # 2.10.2 -> release/2.10 + version="$(./scripts/version.sh)" + release_branch=release/${version%.*} + branch_contains_tag=$(git branch --remotes --contains "${GITHUB_REF}" --list "*/${release_branch}" --format='%(refname)') + if [[ -z "${branch_contains_tag}" ]]; then + echo "Ref tag must exist in a branch named ${release_branch} when creating a release, did you use scripts/release.sh?" + exit 1 + fi + + if [[ -z "${CODER_RELEASE_NOTES}" ]]; then + echo "Release notes are required to create a release, did you use scripts/release.sh?" + exit 1 + fi + + echo "Release inputs verified:" + echo + echo "- Ref: ${GITHUB_REF}" + echo "- Version: ${version}" + echo "- Release channel: ${CODER_RELEASE_CHANNEL}" + echo "- Release branch: ${release_branch}" + echo "- Release notes: true" + + - name: Create release notes file run: | set -euo pipefail - ref=HEAD - old_version="$(git describe --abbrev=0 "$ref^1")" - version="v$(./scripts/version.sh)" - # Generate notes. release_notes_file="$(mktemp -t release_notes.XXXXXX)" - ./scripts/release/generate_release_notes.sh --check-for-changelog --old-version "$old_version" --new-version "$version" --ref "$ref" >> "$release_notes_file" + echo "$CODER_RELEASE_NOTES" > "$release_notes_file" echo CODER_RELEASE_NOTES_FILE="$release_notes_file" >> $GITHUB_ENV - name: Show release notes @@ -265,6 +296,9 @@ jobs: set -euo pipefail publish_args=() + if [[ $CODER_RELEASE_CHANNEL == "stable" ]]; then + publish_args+=(--stable) + fi if [[ $CODER_DRY_RUN == *t* ]]; then publish_args+=(--dry-run) fi diff --git a/scripts/release.sh b/scripts/release.sh index be0cfd8a90..4c5caa5f3e 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -44,12 +44,16 @@ EOH } branch=main +remote=origin dry_run=0 ref= increment= force=0 +script_check=1 +mainline=1 +channel=mainline -args="$(getopt -o h -l dry-run,help,ref:,major,minor,patch,force -- "$@")" +args="$(getopt -o h -l dry-run,help,ref:,mainline,stable,major,minor,patch,force,ignore-script-out-of-date -- "$@")" eval set -- "$args" while true; do case "$1" in @@ -61,6 +65,16 @@ while true; do usage exit 0 ;; + --mainline) + mainline=1 + channel=mainline + shift + ;; + --stable) + mainline=0 + channel=stable + shift + ;; --ref) ref="$2" shift 2 @@ -76,6 +90,12 @@ while true; do force=1 shift ;; + # Allow the script to be run with an out-of-date script for + # development purposes. + --ignore-script-out-of-date) + script_check=0 + shift + ;; --) shift break @@ -87,88 +107,226 @@ while true; do done # Check dependencies. -dependencies gh sort +dependencies gh jq sort if [[ -z $increment ]]; then # Default to patch versions. increment="patch" fi +# Check if the working directory is clean. +if ! git diff --quiet --exit-code; then + log "Working directory is not clean, it is highly recommended to stash changes." + while [[ ! ${stash:-} =~ ^[YyNn]$ ]]; do + read -p "Stash changes? (y/n) " -n 1 -r stash + log + done + if [[ ${stash} =~ ^[Yy]$ ]]; then + maybedryrun "${dry_run}" git stash push --message "scripts/release.sh: autostash" + fi + log +fi + +# Check if the main is up-to-date with the remote. +log "Checking remote ${remote} for repo..." +remote_url=$(git remote get-url "${remote}") +# Allow either SSH or HTTPS URLs. +if ! [[ ${remote_url} =~ [@/]github.com ]] && ! [[ ${remote_url} =~ [:/]coder/coder(\.git)?$ ]]; then + error "This script is only intended to be run with github.com/coder/coder repository set as ${remote}." +fi + # Make sure the repository is up-to-date before generating release notes. -log "Fetching $branch and tags from origin..." -git fetch --quiet --tags origin "$branch" +log "Fetching ${branch} and tags from ${remote}..." +git fetch --quiet --tags "${remote}" "$branch" # Resolve to the latest ref on origin/main unless otherwise specified. -ref=$(git rev-parse --short "${ref:-origin/$branch}") +ref_name=${ref:-${remote}/${branch}} +ref=$(git rev-parse --short "${ref_name}") # Make sure that we're running the latest release script. -if [[ -n $(git diff --name-status origin/"$branch" -- ./scripts/release.sh) ]]; then +script_diff=$(git diff --name-status "${remote}/${branch}" -- scripts/release.sh) +if [[ ${script_check} = 1 ]] && [[ -n ${script_diff} ]]; 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. -log "Checking GitHub for latest release..." -versions_out="$(gh api -H "Accept: application/vnd.github+json" /repos/coder/coder/git/refs/tags -q '.[].ref | split("/") | .[2]' | grep '^v' | sort -r -V)" -mapfile -t versions <<<"$versions_out" -old_version=${versions[0]} -log "Latest release: $old_version" +# Make sure no other release contains this ref. +release_contains_ref="$(git branch --remotes --contains "${ref}" --list "${remote}/release/*" --format='%(refname)')" +if [[ -n ${release_contains_ref} ]]; then + error "Ref ${ref_name} is already part of another release: $(git describe --always "${ref}") on ${release_contains_ref#"refs/remotes/${remote}/"}." +fi + +log "Checking GitHub for latest release(s)..." + +# Check the latest version tag from GitHub (by version) using the API. +versions_out="$(gh api -H "Accept: application/vnd.github+json" /repos/coder/coder/git/refs/tags -q '.[].ref | split("/") | .[2]' | grep '^v[0-9]' | sort -r -V)" +mapfile -t versions <<<"${versions_out}" +latest_mainline_version=${versions[0]} + +latest_stable_version="$(curl -fsSLI -o /dev/null -w "%{url_effective}" https://github.com/coder/coder/releases/latest)" +latest_stable_version="${latest_stable_version#https://github.com/coder/coder/releases/tag/}" + +log "Latest mainline release: ${latest_mainline_version}" +log "Latest stable release: ${latest_stable_version}" log +old_version=${latest_mainline_version} +if ((!mainline)); then + old_version=${latest_stable_version} +fi + trap 'log "Check commit metadata failed, you can try to set \"export CODER_IGNORE_MISSING_COMMIT_METADATA=1\" and try again, if you know what you are doing."' EXIT # shellcheck source=scripts/release/check_commit_metadata.sh source "$SCRIPT_DIR/release/check_commit_metadata.sh" "$old_version" "$ref" trap - EXIT +log tag_version_args=(--old-version "$old_version" --ref "$ref" --"$increment") if ((force == 1)); then tag_version_args+=(--force) fi log "Executing DRYRUN of release tagging..." -new_version="$(execrelative ./release/tag_version.sh "${tag_version_args[@]}" --dry-run)" -log -read -p "Continue? (y/n) " -n 1 -r continue_release +tag_version_out="$(execrelative ./release/tag_version.sh "${tag_version_args[@]}" --dry-run)" log +while [[ ! ${continue_release:-} =~ ^[YyNn]$ ]]; do + read -p "Continue? (y/n) " -n 1 -r continue_release + log +done if ! [[ $continue_release =~ ^[Yy]$ ]]; then exit 0 fi - -release_notes="$(execrelative ./release/generate_release_notes.sh --check-for-changelog --old-version "$old_version" --new-version "$new_version" --ref "$ref")" - -read -p "Preview release notes? (y/n) " -n 1 -r show_reply log -if [[ $show_reply =~ ^[Yy]$ ]]; then + +mapfile -d ' ' -t tag_version <<<"$tag_version_out" +release_branch=${tag_version[0]} +new_version=${tag_version[1]} +new_version="${new_version%$'\n'}" # Remove the trailing newline. + +release_notes="$(execrelative ./release/generate_release_notes.sh --old-version "$old_version" --new-version "$new_version" --ref "$ref")" + +release_notes_file="build/RELEASE-${new_version}.md" +if ((dry_run)); then + release_notes_file="build/RELEASE-${new_version}-DRYRUN.md" +fi +get_editor() { + if command -v editor >/dev/null; then + readlink -f "$(command -v editor || true)" + elif [[ -n ${GIT_EDITOR:-} ]]; then + echo "${GIT_EDITOR}" + elif [[ -n ${EDITOR:-} ]]; then + echo "${EDITOR}" + fi +} +editor="$(get_editor)" +write_release_notes() { + if [[ -z ${editor} ]]; then + log "Release notes written to $release_notes_file, you can now edit this file manually." + else + log "Release notes written to $release_notes_file, you can now edit this file manually or via your editor." + fi + echo -e "${release_notes}" >"${release_notes_file}" +} +log "Writing release notes to ${release_notes_file}" +if [[ -f ${release_notes_file} ]]; then + log + while [[ ! ${overwrite:-} =~ ^[YyNn]$ ]]; do + read -p "Release notes already exists, overwrite? (y/n) " -n 1 -r overwrite + log + done + log + if [[ ${overwrite} =~ ^[Yy]$ ]]; then + write_release_notes + else + log "Release notes not overwritten, using existing release notes." + release_notes="$(<"$release_notes_file")" + fi +else + write_release_notes +fi +log + +if [[ -z ${editor} ]]; then + log "No editor found, please set the \$EDITOR environment variable for edit prompt." +else + while [[ ! ${edit:-} =~ ^[YyNn]$ ]]; do + read -p "Edit release notes in \"${editor}\"? (y/n) " -n 1 -r edit + log + done + if [[ ${edit} =~ ^[Yy]$ ]]; then + "${editor}" "${release_notes_file}" + release_notes2="$(<"$release_notes_file")" + if [[ "${release_notes}" != "${release_notes2}" ]]; then + log "Release notes have been updated!" + release_notes="${release_notes2}" + else + log "No changes detected..." + fi + fi +fi +log + +while [[ ! ${preview:-} =~ ^[YyNn]$ ]]; do + read -p "Preview release notes? (y/n) " -n 1 -r preview + log +done +if [[ ${preview} =~ ^[Yy]$ ]]; then log echo -e "$release_notes\n" fi - -read -p "Create release? (y/n) " -n 1 -r create log -if ! [[ $create =~ ^[Yy]$ ]]; then + +while [[ ! ${create:-} =~ ^[YyNn]$ ]]; do + read -p "Create, build and publish release? (y/n) " -n 1 -r create + log +done +if ! [[ ${create} =~ ^[Yy]$ ]]; then exit 0 fi - log + # Run without dry-run to actually create the tag, note we don't update the # new_version variable here to ensure we're pushing what we showed before. maybedryrun "$dry_run" execrelative ./release/tag_version.sh "${tag_version_args[@]}" >/dev/null +maybedryrun "$dry_run" git push -u origin "$release_branch" maybedryrun "$dry_run" git push --tags -u origin "$new_version" +log +log "Release tags for ${new_version} created successfully and pushed to ${remote}!" + +log +# Write to a tmp file for ease of debugging. +release_json_file=$(mktemp -t coder-release.json) +log "Writing release JSON to ${release_json_file}" +jq -n \ + --argjson dry_run "${dry_run}" \ + --arg release_channel "${channel}" \ + --arg release_notes "${release_notes}" \ + '{dry_run: ($dry_run > 0) | tostring, release_channel: $release_channel, release_notes: $release_notes}' \ + >"${release_json_file}" + +log "Running release workflow..." +maybedryrun "${dry_run}" cat "${release_json_file}" | + maybedryrun "${dry_run}" gh workflow run release.yaml --json --ref "${new_version}" + +log +log "Release workflow started successfully!" + if ((dry_run)); then # We can't watch the release.yaml workflow if we're in dry-run mode. exit 0 fi log -read -p "Watch release? (y/n) " -n 1 -r watch -log -if ! [[ $watch =~ ^[Yy]$ ]]; then +while [[ ! ${watch:-} =~ ^[YyNn]$ ]]; do + read -p "Watch release? (y/n) " -n 1 -r watch + log +done +if ! [[ ${watch} =~ ^[Yy]$ ]]; then exit 0 fi log 'Waiting for job to become "in_progress"...' -# Wait at most 3 minutes (3*60)/3 = 60 for the job to start. +# Wait at most 10 minutes (60*10/60) for the job to start. for _ in $(seq 1 60); do output="$( # Output: @@ -181,7 +339,7 @@ for _ in $(seq 1 60); do )" mapfile -t run <<<"$output" if [[ ${run[1]} != "in_progress" ]]; then - sleep 3 + sleep 10 continue fi gh run watch --exit-status "${run[0]}" diff --git a/scripts/release/check_commit_metadata.sh b/scripts/release/check_commit_metadata.sh index 02a3952536..dd3f1fb6db 100755 --- a/scripts/release/check_commit_metadata.sh +++ b/scripts/release/check_commit_metadata.sh @@ -19,26 +19,29 @@ source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/lib.sh" from_ref=${1:-} to_ref=${2:-} -if [[ -z $from_ref ]]; then +if [[ -z ${from_ref} ]]; then error "No from_ref specified" fi -if [[ -z $to_ref ]]; then +if [[ -z ${to_ref} ]]; then error "No to_ref specified" fi -range="$from_ref..$to_ref" +range="${from_ref}..${to_ref}" # Check dependencies. dependencies gh COMMIT_METADATA_BREAKING=0 -declare -A COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY COMMIT_METADATA_AUTHORS +declare -a COMMIT_METADATA_COMMITS +declare -A COMMIT_METADATA_TITLE COMMIT_METADATA_HUMAN_TITLE COMMIT_METADATA_CATEGORY COMMIT_METADATA_AUTHORS # 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() { + log "Checking commit metadata for changes between ${from_ref} and ${to_ref}..." + # Match a commit prefix pattern, e.g. feat: or feat(site):. prefix_pattern="^([a-z]+)(\([^)]+\))?:" @@ -55,14 +58,93 @@ main() { security_label=security security_category=security - # Get abbreviated and full commit hashes and titles for each commit. - git_log_out="$(git log --no-merges --pretty=format:"%h %H %s" "$range")" - mapfile -t commits <<<"$git_log_out" + # Order is important here, first partial match wins. + declare -A humanized_areas=( + ["agent/agentssh"]="Agent SSH" + ["coderd/database"]="Database" + ["enterprise/audit"]="Auditing" + ["enterprise/cli"]="CLI" + ["enterprise/coderd"]="Server" + ["enterprise/dbcrypt"]="Database" + ["enterprise/derpmesh"]="Networking" + ["enterprise/provisionerd"]="Provisioner" + ["enterprise/tailnet"]="Networking" + ["enterprise/wsproxy"]="Workspace Proxy" + [agent]="Agent" + [cli]="CLI" + [coderd]="Server" + [codersdk]="SDK" + [docs]="Documentation" + [enterprise]="Enterprise" + [examples]="Examples" + [helm]="Helm" + [install.sh]="Installer" + [provisionersdk]="SDK" + [provisionerd]="Provisioner" + [provisioner]="Provisioner" + [pty]="CLI" + [scaletest]="Scale Testing" + [site]="Dashboard" + [support]="Support" + [tailnet]="Networking" + ) - # 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 hashes for all cherry-picked commits between the selected ref + # and main. These are sorted by commit title so that we can group + # two cherry-picks together. + declare -A cherry_pick_commits + git_cherry_out=$( + { + git log --no-merges --cherry-mark --pretty=format:"%m %H %s" "${to_ref}...origin/main" + git log --no-merges --cherry-mark --pretty=format:"%m %H %s" "${from_ref}...origin/main" + } | { grep '^=' || true; } | sort -u | sort -k3 + ) + if [[ -n ${git_cherry_out} ]]; then + mapfile -t cherry_picks <<<"${git_cherry_out}" + # Iterate over the array in groups of two + for ((i = 0; i < ${#cherry_picks[@]}; i += 2)); do + mapfile -d ' ' -t parts1 <<<"${cherry_picks[i]}" + mapfile -d ' ' -t parts2 <<<"${cherry_picks[i + 1]}" + commit1=${parts1[1]} + title1=${parts1[*]:2} + commit2=${parts2[1]} + title2=${parts2[*]:2} + + if [[ ${title1} != "${title2}" ]]; then + error "Invariant failed, cherry-picked commits have different titles: ${title1} != ${title2}" + fi + + cherry_pick_commits[${commit1}]=${commit2} + cherry_pick_commits[${commit2}]=${commit1} + done + fi + + # Get abbreviated and full commit hashes and titles for each commit. + git_log_out="$(git log --no-merges --left-right --pretty=format:"%m %h %H %s" "${range}")" + if [[ -z ${git_log_out} ]]; then + error "No commits found in range ${range}" + fi + mapfile -t commits <<<"${git_log_out}" + + # Get the lowest committer date of the commits so that we can fetch + # the PRs that were merged. + lookback_date=$( + { + # Check all included commits. + for commit in "${commits[@]}"; do + mapfile -d ' ' -t parts <<<"${commit}" + sha_long=${parts[2]} + git show --no-patch --date=short --format='%cd' "${sha_long}" + done + # Include cherry-picks and their original commits (the + # original commit may be older than the cherry pick). + for cherry_pick in "${cherry_picks[@]}"; do + mapfile -d ' ' -t parts <<<"${cherry_pick}" + sha_long=${parts[1]} + git show --no-patch --date=short --format='%cd' "${sha_long}" + done + } | sort -t- -n | head -n 1 + ) # 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 @@ -78,84 +160,135 @@ main() { --base main \ --state merged \ --limit 10000 \ - --search "merged:>=$from_commit_date" \ + --search "merged:>=${lookback_date}" \ --json mergeCommit,labels,author \ --jq '.[] | "\( .mergeCommit.oid ) author:\( .author.login ) labels:\(["label:\( .labels[].name )"] | join(" "))"' )" declare -A authors labels - if [[ -n $pr_list_out ]]; then - mapfile -t pr_metadata_raw <<<"$pr_list_out" + if [[ -n ${pr_list_out} ]]; then + mapfile -t pr_metadata_raw <<<"${pr_list_out}" for entry in "${pr_metadata_raw[@]}"; do commit_sha_long=${entry%% *} commit_author=${entry#* author:} commit_author=${commit_author%% *} - authors[$commit_sha_long]=$commit_author + authors[${commit_sha_long}]=${commit_author} all_labels=${entry#* labels:} - labels[$commit_sha_long]=$all_labels + labels[${commit_sha_long}]=${all_labels} done fi for commit in "${commits[@]}"; do - mapfile -d ' ' -t parts <<<"$commit" - commit_sha_short=${parts[0]} - commit_sha_long=${parts[1]} - commit_prefix=${parts[2]} + mapfile -d ' ' -t parts <<<"${commit}" + left_right=${parts[0]} # From `git log --left-right`, see `man git-log` for details. + commit_sha_short=${parts[1]} + commit_sha_long=${parts[2]} + commit_prefix=${parts[3]} + title=${parts[*]:3} + title=${title%$'\n'} + title_no_prefix=${parts[*]:4} + title_no_prefix=${title_no_prefix%$'\n'} + + # For COMMIT_METADATA_COMMITS in case of cherry-pick override. + commit_sha_long_orig=${commit_sha_long} + + # Check if this is a potential cherry-pick. + if [[ -v cherry_pick_commits[${commit_sha_long}] ]]; then + # Is this the cherry-picked or the original commit? + if [[ ! -v authors[${commit_sha_long}] ]] || [[ ! -v labels[${commit_sha_long}] ]]; then + log "Cherry-picked commit ${commit_sha_long}, checking original commit ${cherry_pick_commits[${commit_sha_long}]}" + # Use the original commit's metadata from GitHub. + commit_sha_long=${cherry_pick_commits[${commit_sha_long}]} + else + # Skip the cherry-picked commit, we only need the original. + log "Skipping commit ${commit_sha_long} cherry-picked into ${from_ref} as ${cherry_pick_commits[${commit_sha_long}]} (${title})" + continue + fi + fi + + if [[ ${left_right} == "<" ]]; then + # Skip commits that are already in main. + log "Skipping commit ${commit_sha_short} from other branch (${commit_sha_long} ${title})" + continue + fi + + COMMIT_METADATA_COMMITS+=("${commit_sha_long_orig}") # Safety-check, guarantee all commits had their metadata fetched. - if [[ ! -v authors[$commit_sha_long] ]] || [[ ! -v labels[$commit_sha_long] ]]; then - if [[ $ignore_missing_metadata != 1 ]]; then - error "Metadata missing for commit $commit_sha_short" + if [[ ! -v authors[${commit_sha_long}] ]] || [[ ! -v labels[${commit_sha_long}] ]]; then + if [[ ${ignore_missing_metadata} != 1 ]]; then + error "Metadata missing for commit ${commit_sha_short} (${commit_sha_long})" else - log "WARNING: Metadata missing for commit $commit_sha_short" + log "WARNING: Metadata missing for commit ${commit_sha_short} (${commit_sha_long})" fi fi # Store the commit title for later use. - title=${parts[*]:2} - title=${title%$'\n'} - COMMIT_METADATA_TITLE[$commit_sha_short]=$title - if [[ -v authors[$commit_sha_long] ]]; then - COMMIT_METADATA_AUTHORS[$commit_sha_short]="@${authors[$commit_sha_long]}" + COMMIT_METADATA_TITLE[${commit_sha_short}]=${title} + if [[ -v authors[${commit_sha_long}] ]]; then + COMMIT_METADATA_AUTHORS[${commit_sha_short}]="@${authors[${commit_sha_long}]}" + fi + + # Create humanized titles where possible, examples: + # + # "feat: add foo" -> "Add foo". + # "feat(site): add bar" -> "Dashboard: Add bar". + COMMIT_METADATA_HUMAN_TITLE[${commit_sha_short}]=${title} + if [[ ${commit_prefix} =~ ${prefix_pattern} ]]; then + sub=${BASH_REMATCH[2]} + if [[ -z ${sub} ]]; then + # No parenthesis found, simply drop the prefix. + COMMIT_METADATA_HUMAN_TITLE[${commit_sha_short}]="${title_no_prefix^}" + else + # Drop the prefix and replace it with a humanized area, + # leave as-is for unknown areas. + sub=${sub#(} + for area in "${!humanized_areas[@]}"; do + if [[ ${sub} = "${area}"* ]]; then + COMMIT_METADATA_HUMAN_TITLE[${commit_sha_short}]="${humanized_areas[${area}]}: ${title_no_prefix^}" + break + fi + done + fi fi # 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 + 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 - COMMIT_METADATA_CATEGORY[$commit_sha_short]=$security_category + elif [[ ${labels[${commit_sha_long}]:-} = *"label:${security_label}"* ]]; then + COMMIT_METADATA_CATEGORY[${commit_sha_short}]=${security_category} continue - elif [[ ${labels[$commit_sha_long]:-} = *"label:$experimental_label"* ]]; then - COMMIT_METADATA_CATEGORY[$commit_sha_short]=$experimental_category + elif [[ ${labels[${commit_sha_long}]:-} = *"label:${experimental_label}"* ]]; then + COMMIT_METADATA_CATEGORY[${commit_sha_short}]=${experimental_category} continue fi - if [[ $commit_prefix =~ $prefix_pattern ]]; then + if [[ ${commit_prefix} =~ ${prefix_pattern} ]]; then commit_prefix=${BASH_REMATCH[1]} fi - case $commit_prefix in + case ${commit_prefix} in # From: https://github.com/commitizen/conventional-commit-types feat | fix | docs | style | refactor | perf | test | build | ci | chore | revert) - COMMIT_METADATA_CATEGORY[$commit_sha_short]=$commit_prefix + COMMIT_METADATA_CATEGORY[${commit_sha_short}]=${commit_prefix} ;; *) - COMMIT_METADATA_CATEGORY[$commit_sha_short]=other + COMMIT_METADATA_CATEGORY[${commit_sha_short}]=other ;; esac done } declare_print_commit_metadata() { - declare -p COMMIT_METADATA_BREAKING COMMIT_METADATA_TITLE COMMIT_METADATA_CATEGORY COMMIT_METADATA_AUTHORS + declare -p COMMIT_METADATA_COMMITS COMMIT_METADATA_BREAKING COMMIT_METADATA_TITLE COMMIT_METADATA_HUMAN_TITLE COMMIT_METADATA_CATEGORY COMMIT_METADATA_AUTHORS } 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_AUTHORS + export _COMMIT_METADATA_CACHE COMMIT_METADATA_COMMITS COMMIT_METADATA_BREAKING COMMIT_METADATA_TITLE COMMIT_METADATA_HUMAN_TITLE COMMIT_METADATA_CATEGORY COMMIT_METADATA_AUTHORS } # _COMMIT_METADATA_CACHE is used to cache the results of this script in @@ -163,7 +296,7 @@ export_commit_metadata() { if [[ ${_COMMIT_METADATA_CACHE:-} == "${range}:"* ]]; then eval "${_COMMIT_METADATA_CACHE#*:}" else - if [[ $ignore_missing_metadata == 1 ]]; then + if [[ ${ignore_missing_metadata} == 1 ]]; then log "WARNING: Ignoring missing commit metadata, breaking changes may be missed." fi main diff --git a/scripts/release/generate_release_notes.sh b/scripts/release/generate_release_notes.sh index be3cfdd79d..d3990a736f 100755 --- a/scripts/release/generate_release_notes.sh +++ b/scripts/release/generate_release_notes.sh @@ -18,16 +18,12 @@ source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/lib.sh" old_version= new_version= ref= -check_for_changelog=0 +mainline=1 -args="$(getopt -o '' -l check-for-changelog,old-version:,new-version:,ref: -- "$@")" -eval set -- "$args" +args="$(getopt -o '' -l old-version:,new-version:,ref:,mainline,stable -- "$@")" +eval set -- "${args}" while true; do case "$1" in - --check-for-changelog) - check_for_changelog=1 - shift - ;; --old-version) old_version="$2" shift 2 @@ -40,6 +36,14 @@ while true; do ref="$2" shift 2 ;; + --mainline) + mainline=1 + shift + ;; + --stable) + mainline=0 + shift + ;; --) shift break @@ -53,34 +57,31 @@ done # Check dependencies. dependencies gh sort -if [[ -z $old_version ]]; then +if [[ -z ${old_version} ]]; then error "No old version specified" fi -if [[ -z $new_version ]]; then +if [[ -z ${new_version} ]]; then error "No new version specified" fi -if [[ $new_version != v* ]]; then +if [[ ${new_version} != v* ]]; then error "New version must start with a v" fi -if [[ -z $ref ]]; then +if [[ -z ${ref} ]]; then error "No ref specified" fi -# Use a manual changelog, if present -changelog_path="$(git rev-parse --show-toplevel)/docs/changelogs/$new_version.md" -if [ "$check_for_changelog" -eq 1 ]; then - if [ -f "$changelog_path" ]; then - cat "$changelog_path" - exit 0 - fi -fi - # shellcheck source=scripts/release/check_commit_metadata.sh -source "$SCRIPT_DIR/check_commit_metadata.sh" "$old_version" "$ref" +source "${SCRIPT_DIR}/check_commit_metadata.sh" "${old_version}" "${ref}" # Sort commits by title prefix, then by date, only return sha at the end. -git_log_out="$(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_out" +git_show_out="$( + { + echo "${COMMIT_METADATA_COMMITS[@]}" | + tr ' ' '\n' | + xargs git show --no-patch --pretty=format:"%ct %h %s" + } | sort -k3,3 -k1,1n | cut -d' ' -f2 +)" +mapfile -t commits <<<"${git_show_out}" # From: https://github.com/commitizen/conventional-commit-types # NOTE(mafredri): These need to be supported in check_commit_metadata.sh as well. @@ -121,56 +122,80 @@ declare -A section_titles=( # Verify that all items in section_order exist as keys in section_titles and # vice-versa. for cat in "${section_order[@]}"; do - if [[ " ${!section_titles[*]} " != *" $cat "* ]]; then - error "BUG: category $cat does not exist in section_titles" + if [[ " ${!section_titles[*]} " != *" ${cat} "* ]]; then + error "BUG: category ${cat} does not exist in section_titles" fi done for cat in "${!section_titles[@]}"; do - if [[ " ${section_order[*]} " != *" $cat "* ]]; then - error "BUG: Category $cat does not exist in section_order" + if [[ " ${section_order[*]} " != *" ${cat} "* ]]; then + error "BUG: Category ${cat} does not exist in section_order" fi done for commit in "${commits[@]}"; do - line="- $commit ${COMMIT_METADATA_TITLE[$commit]}" - if [[ -v COMMIT_METADATA_AUTHORS[$commit] ]]; then - line+=" (${COMMIT_METADATA_AUTHORS[$commit]})" + title=${COMMIT_METADATA_TITLE[${commit}]} + if [[ -v COMMIT_METADATA_HUMAN_TITLE[${commit}] ]]; then + title=${COMMIT_METADATA_HUMAN_TITLE[${commit}]} + fi + + if [[ ${title} =~ \(#[0-9]*\)$ ]]; then + title="${title%)}, ${commit})" + else + title="${title} (${commit})" + fi + line="- ${title}" + line=${line//) (/, )} + if [[ -v COMMIT_METADATA_AUTHORS[${commit}] ]]; then + line+=" (${COMMIT_METADATA_AUTHORS[${commit}]})" fi # Default to "other" category. cat=other for c in "${!section_titles[@]}"; do - if [[ $c == "${COMMIT_METADATA_CATEGORY[$commit]}" ]]; then - cat=$c + if [[ ${c} == "${COMMIT_METADATA_CATEGORY[${commit}]}" ]]; then + cat=${c} break fi done - declare "$cat"_changelog+="$line"$'\n' + declare "${cat}"_changelog+="${line}"$'\n' done changelog="$( for cat in "${section_order[@]}"; do changes="$(eval "echo -e \"\${${cat}_changelog:-}\"")" if ((${#changes} > 0)); then - echo -e "\n### ${section_titles["$cat"]}\n" - if [[ $cat == experimental ]]; then + echo -e "\n### ${section_titles["${cat}"]}\n" + if [[ ${cat} == experimental ]]; then echo -e "These changes are feature-flagged and can be enabled with the \`--experiments\` server flag. They may change or be removed in future releases.\n" fi - echo -e "$changes" + echo -e "${changes}" fi done )" -image_tag="$(execrelative ../image_tag.sh --version "$new_version")" +image_tag="$(execrelative ../image_tag.sh --version "${new_version}")" -echo -e "## Changelog -$changelog +blurb= +stable_since= +if ((mainline)); then + blurb=" +> [!NOTE] +> This is a mainline Coder release. We advise enterprise customers without a staging environment to install our [latest stable release](https://github.com/coder/coder/releases/latest) while we refine this version. Learn more about our [Release Schedule](https://coder.com/docs/v2/latest/install/releases). +" +else + # Date format: April 23, 2024 + d=$(date +'%B %d, %Y') + stable_since="> ## Stable (since ${d})"$'\n\n' +fi -Compare: [\`$old_version...$new_version\`](https://github.com/coder/coder/compare/$old_version...$new_version) +echo -e "${stable_since}## Changelog +${blurb}${changelog} + +Compare: [\`${old_version}...${new_version}\`](https://github.com/coder/coder/compare/${old_version}...${new_version}) ## Container image -- \`docker pull $image_tag\` +- \`docker pull ${image_tag}\` ## Install/upgrade diff --git a/scripts/release/publish.sh b/scripts/release/publish.sh index 5c656aa0f8..68dbf468f4 100755 --- a/scripts/release/publish.sh +++ b/scripts/release/publish.sh @@ -33,14 +33,19 @@ if [[ "${CI:-}" == "" ]]; then error "This script must be run in CI" fi +stable=0 version="" release_notes_file="" dry_run=0 -args="$(getopt -o "" -l version:,release-notes-file:,dry-run -- "$@")" +args="$(getopt -o "" -l stable,version:,release-notes-file:,dry-run -- "$@")" eval set -- "$args" while true; do case "$1" in + --stable) + stable=1 + shift + ;; --version) version="$2" shift 2 @@ -169,10 +174,24 @@ popd log log +latest=false +if [[ "$stable" == 1 ]]; then + latest=true +fi + +target_commitish=main # This is the default. +release_branch_refname=$(git branch --remotes --contains "${new_tag}" --format '%(refname)' '*/release/*') +if [[ -n "${release_branch_refname}" ]]; then + # refs/remotes/origin/release/2.9 -> release/2.9 + target_commitish="release/${release_branch_refname#*release/}" +fi + # We pipe `true` into `gh` so that it never tries to be interactive. true | maybedryrun "$dry_run" gh release create \ + --latest="$latest" \ --title "$new_tag" \ + --target "$target_commitish" \ --notes-file "$release_notes_file" \ "$new_tag" \ "$temp_dir"/* diff --git a/scripts/release/tag_version.sh b/scripts/release/tag_version.sh index e23bd998d2..7233931c63 100755 --- a/scripts/release/tag_version.sh +++ b/scripts/release/tag_version.sh @@ -79,13 +79,9 @@ fi if [[ -z $old_version ]]; then old_version="$(git describe --abbrev=0 "$ref^1" --always)" fi -cur_tag="$(git describe --abbrev=0 "$ref" --always)" -if [[ $old_version != "$cur_tag" ]]; then - error "A newer tag than \"$old_version\" already exists for \"$ref\" ($cur_tag), aborting." -fi +ref_name=${ref} 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/check_commit_metadata.sh" "$old_version" "$ref" @@ -109,8 +105,23 @@ else fi mapfile -d . -t version_parts <<<"${old_version#v}" +release_branch_prefix="release/" +release_ff=0 case "$increment" in patch) + release_branch="${release_branch_prefix}${version_parts[0]}.${version_parts[1]}" + branch_contains_ref=$(git branch --remotes --contains "${ref}" --list "*/${release_branch}" --format='%(refname)') + if [[ -z $branch_contains_ref ]]; then + # Allow patch if we can fast-forward to ref, no need for dry-run here + # since we're not checking out the branch and deleting it afterwards. + git branch --no-track "${release_branch}-ff" "origin/${release_branch}" + if ! git merge --ff-only --into-name "${release_branch}-ff" "${ref}" >/dev/null 2>&1; then + git branch -D "${release_branch}-ff" + error "Provided ref (${ref_name}) is not in the required release branch (${release_branch}) and cannot be fast-forwarded, unable to increment patch version. Please increment minor or major." + fi + release_ff=1 + git branch -D "${release_branch}-ff" + fi version_parts[2]=$((version_parts[2] + 1)) ;; minor) @@ -118,13 +129,7 @@ minor) version_parts[2]=0 ;; major) - # Jump from v0.x to v2.x to avoid naming conflicts - # with Coder v1 (https://coder.com/docs/v1) - if [ "${version_parts[0]}" -eq 0 ]; then - version_parts[0]=2 - else - version_parts[0]=$((version_parts[0] + 1)) - fi + version_parts[0]=$((version_parts[0] + 1)) version_parts[1]=0 version_parts[2]=0 ;; @@ -133,10 +138,25 @@ major) ;; esac +release_branch="${release_branch_prefix}${version_parts[0]}.${version_parts[1]}" new_version="v${version_parts[0]}.${version_parts[1]}.${version_parts[2]}" log "Old version: $old_version" log "New version: $new_version" +log "Release branch: $release_branch" +if [[ ${increment} = patch ]]; then + if ((release_ff == 1)); then + log "Fast-forwarding release branch" + maybedryrun "$dry_run" git checkout "${release_branch}" + maybedryrun "$dry_run" git merge --ff-only "${ref}" + else + log "Using existing release branch" + maybedryrun "$dry_run" git checkout "${release_branch}" + fi +else + log "Creating new release branch" + maybedryrun "$dry_run" git checkout -b "${release_branch}" "${ref}" +fi maybedryrun "$dry_run" git tag -a "$new_version" -m "Release $new_version" "$ref" -echo "$new_version" +echo "${release_branch} ${new_version}" diff --git a/scripts/version.sh b/scripts/version.sh index 42b5536da6..eba2f63cbc 100755 --- a/scripts/version.sh +++ b/scripts/version.sh @@ -16,33 +16,35 @@ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" cdroot # If in Sapling, just print the commit since we don't have tags. -if [ -d ".sl" ]; then +if [[ -d ".sl" ]]; then sl log -l 1 | awk '/changeset/ { printf "0.0.0+sl-%s\n", substr($2, 0, 16) }' exit 0 fi -if [[ "${CODER_FORCE_VERSION:-}" != "" ]]; then - echo "$CODER_FORCE_VERSION" +if [[ -n "${CODER_FORCE_VERSION:-}" ]]; then + echo "${CODER_FORCE_VERSION}" exit 0 fi # To make contributing easier, if the upstream isn't coder/coder and there are # no tags we will fall back to 0.1.0 with devel suffix. -if [[ "$(git remote get-url origin)" != *coder/coder* ]] && [[ "$(git tag)" == "" ]]; then +remote_url=$(git remote get-url origin) +tag_list=$(git tag) +if ! [[ ${remote_url} =~ [@/]github.com ]] && ! [[ ${remote_url} =~ [:/]coder/coder(\.git)?$ ]] && [[ -z ${tag_list} ]]; then log log "INFO(version.sh): It appears you've checked out a fork of Coder." log "INFO(version.sh): By default GitHub does not include tags when forking." - log "INFO(version.sh): We will use the default version 0.1.0 for this build." + log "INFO(version.sh): We will use the default version 2.0.0 for this build." log "INFO(version.sh): To pull tags from upstream, use the following commands:" log "INFO(version.sh): - git remote add upstream https://github.com/coder/coder.git" log "INFO(version.sh): - git fetch upstream" log - last_tag="v0.1.0" + last_tag="v2.0.0" else last_tag="$(git describe --tags --abbrev=0)" fi -version="$last_tag" +version="${last_tag}" # If the HEAD has extra commits since the last tag then we are in a dev version. # @@ -51,11 +53,11 @@ version="$last_tag" if [[ "${CODER_RELEASE:-}" == *t* ]]; then # $last_tag will equal `git describe --always` if we currently have the tag # checked out. - if [[ "$last_tag" != "$(git describe --always)" ]]; then + if [[ "${last_tag}" != "$(git describe --always)" ]]; then # make won't exit on $(shell cmd) failures, so we have to kill it :( - if [[ "$(ps -o comm= "$PPID" || true)" == *make* ]]; then + if [[ "$(ps -o comm= "${PPID}" || true)" == *make* ]]; then log "ERROR: version.sh: the current commit is not tagged with an annotated tag" - kill "$PPID" || true + kill "${PPID}" || true exit 1 fi