mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
chore: add new interactive release package (#22624)
This commit is contained in:
@@ -700,11 +700,9 @@ jobs:
|
||||
name: Publish to Homebrew tap
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
if: ${{ !inputs.dry_run && inputs.release_channel == 'mainline' }}
|
||||
|
||||
steps:
|
||||
# TODO: skip this if it's not a new release (i.e. a backport). This is
|
||||
# fine right now because it just makes a PR that we can close.
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
|
||||
+7
-440
@@ -1,445 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
# shellcheck source=scripts/lib.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
cdroot
|
||||
|
||||
usage() {
|
||||
cat <<EOH
|
||||
Usage: ./release.sh [--dry-run] [-h | --help] [--ref <ref>] [--major | --minor | --patch] [--force]
|
||||
# Thin wrapper that invokes the Go release tool.
|
||||
# Usage: ./scripts/release.sh [flags]
|
||||
#
|
||||
# Flags are passed directly to the Go program.
|
||||
# Run ./scripts/release.sh --help for details.
|
||||
|
||||
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.
|
||||
|
||||
To mark a release as containing breaking changes, the commit title should
|
||||
either contain a known prefix with an exclamation mark ("feat!:",
|
||||
"feat(coderd)!:") or the PR that was merged can be tagged with the
|
||||
"release/breaking" label.
|
||||
|
||||
GitHub labels that affect release notes:
|
||||
|
||||
- release/breaking: Shown under BREAKING CHANGES, prevents patch release.
|
||||
- release/experimental: Shown at the bottom under Experimental.
|
||||
- security: Shown under SECURITY.
|
||||
|
||||
Flags:
|
||||
|
||||
Set --major or --minor to force a larger version bump, even when there are no
|
||||
breaking changes. By default a patch version will be created, --patch is no-op.
|
||||
|
||||
Set --force force the provided increment to be used (e.g. --patch), even if
|
||||
there are breaking changes, etc.
|
||||
|
||||
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 --dry-run to see what this script would do without making actual changes.
|
||||
EOH
|
||||
}
|
||||
|
||||
branch=main
|
||||
remote=origin
|
||||
dry_run=0
|
||||
ref=
|
||||
increment=
|
||||
force=0
|
||||
script_check=1
|
||||
mainline=1
|
||||
channel=mainline
|
||||
|
||||
# These values will be used for any PRs created.
|
||||
pr_review_assignee=${CODER_RELEASE_PR_REVIEW_ASSIGNEE:-@me}
|
||||
pr_review_reviewer=${CODER_RELEASE_PR_REVIEW_REVIEWER:-bpmct,stirby}
|
||||
|
||||
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
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--mainline)
|
||||
mainline=1
|
||||
channel=mainline
|
||||
shift
|
||||
;;
|
||||
--stable)
|
||||
mainline=0
|
||||
channel=stable
|
||||
shift
|
||||
;;
|
||||
--ref)
|
||||
ref="$2"
|
||||
shift 2
|
||||
;;
|
||||
--major | --minor | --patch)
|
||||
if [[ -n $increment ]]; then
|
||||
error "Cannot specify multiple version increments."
|
||||
fi
|
||||
increment=${1#--}
|
||||
shift
|
||||
;;
|
||||
--force)
|
||||
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
|
||||
;;
|
||||
*)
|
||||
error "Unrecognized option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check dependencies.
|
||||
dependencies gh jq sort
|
||||
|
||||
# Authenticate gh CLI.
|
||||
# NOTE: Coder external-auth won't work because the GitHub App lacks permissions.
|
||||
if [[ -z ${GITHUB_TOKEN:-} ]]; then
|
||||
if [[ -n ${GH_TOKEN:-} ]]; then
|
||||
export GITHUB_TOKEN=${GH_TOKEN}
|
||||
elif token="$(gh auth token --hostname github.com 2>/dev/null)"; then
|
||||
export GITHUB_TOKEN=${token}
|
||||
else
|
||||
error "GitHub authentication is required to run this command, please set GITHUB_TOKEN or run 'gh auth login'."
|
||||
fi
|
||||
fi
|
||||
|
||||
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 ${remote}..."
|
||||
git fetch --quiet --tags "${remote}" "$branch"
|
||||
|
||||
# Resolve to the current commit unless otherwise specified.
|
||||
ref_name=${ref:-HEAD}
|
||||
ref=$(git rev-parse "${ref_name}")
|
||||
|
||||
# Make sure that we're running the latest release script.
|
||||
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
|
||||
|
||||
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_name" --"$increment")
|
||||
if ((force == 1)); then
|
||||
tag_version_args+=(--force)
|
||||
fi
|
||||
log "Executing DRYRUN of release tagging..."
|
||||
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
|
||||
log
|
||||
|
||||
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" --$channel)"
|
||||
|
||||
mkdir -p build
|
||||
release_notes_file="build/RELEASE-${new_version}.md"
|
||||
release_notes_file_dryrun="build/RELEASE-${new_version}-DRYRUN.md"
|
||||
if ((dry_run)); then
|
||||
release_notes_file=${release_notes_file_dryrun}
|
||||
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
|
||||
|
||||
edit_release_notes() {
|
||||
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
|
||||
|
||||
if ((!dry_run)) && [[ -f ${release_notes_file_dryrun} ]]; then
|
||||
release_notes_dryrun="$(<"${release_notes_file_dryrun}")"
|
||||
if [[ "${release_notes}" != "${release_notes_dryrun}" ]]; then
|
||||
log "WARNING: Release notes differ from dry-run version:"
|
||||
log
|
||||
diff -u "${release_notes_file_dryrun}" "${release_notes_file}" || true
|
||||
log
|
||||
continue_with_new_release_notes=
|
||||
while [[ ! ${continue_with_new_release_notes:-} =~ ^[YyNn]$ ]]; do
|
||||
read -p "Continue with the new release notes anyway? (y/n) " -n 1 -r continue_with_new_release_notes
|
||||
log
|
||||
done
|
||||
if [[ ${continue_with_new_release_notes} =~ ^[Nn]$ ]]; then
|
||||
log
|
||||
edit_release_notes
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
edit_release_notes
|
||||
|
||||
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
|
||||
log
|
||||
|
||||
# Prompt user to manually update the release calendar documentation
|
||||
log "IMPORTANT: Please manually update the release calendar documentation before proceeding."
|
||||
log "The release calendar is located at: https://coder.com/docs/install/releases#release-schedule"
|
||||
log "You can also run the update script: ./scripts/update-release-calendar.sh"
|
||||
log
|
||||
while [[ ! ${calendar_updated:-} =~ ^[YyNn]$ ]]; do
|
||||
read -p "Have you updated the release calendar documentation? (y/n) " -n 1 -r calendar_updated
|
||||
log
|
||||
done
|
||||
if ! [[ ${calendar_updated} =~ ^[Yy]$ ]]; then
|
||||
log "Please update the release calendar documentation before proceeding with the release."
|
||||
exit 0
|
||||
fi
|
||||
log
|
||||
|
||||
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.XXXXXX)
|
||||
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!"
|
||||
|
||||
log
|
||||
log "Would you like for me to create a pull request for you to automatically bump the version numbers in the docs?"
|
||||
while [[ ! ${create_pr:-} =~ ^[YyNn]$ ]]; do
|
||||
read -p "Create PR? (y/n) " -n 1 -r create_pr
|
||||
log
|
||||
done
|
||||
if [[ ${create_pr} =~ ^[Yy]$ ]]; then
|
||||
pr_branch=autoversion/${new_version}
|
||||
title="docs: bump ${channel} version to ${new_version}"
|
||||
body="This PR was automatically created by the [release script](https://github.com/coder/coder/blob/main/scripts/release.sh).
|
||||
|
||||
Please review the changes and merge if they look good and the release is complete.
|
||||
|
||||
You can follow the release progress [here](https://github.com/coder/coder/actions/workflows/release.yaml) and view the published release [here](https://github.com/coder/coder/releases/tag/${new_version}) (once complete)."
|
||||
|
||||
log
|
||||
log "Creating branch \"${pr_branch}\" and updating versions..."
|
||||
|
||||
create_pr_stash=0
|
||||
if ! git diff --quiet --exit-code -- docs; then
|
||||
maybedryrun "${dry_run}" git stash push --message "scripts/release.sh: autostash (autoversion)" -- docs
|
||||
create_pr_stash=1
|
||||
fi
|
||||
maybedryrun "${dry_run}" git checkout -b "${pr_branch}" "${remote}/${branch}"
|
||||
maybedryrun "${dry_run}" execrelative ./release/docs_update_experiments.sh
|
||||
execrelative go run ./release autoversion --channel "${channel}" "${new_version}" --dry-run="${dry_run}"
|
||||
maybedryrun "${dry_run}" git add docs
|
||||
maybedryrun "${dry_run}" git commit -m "${title}"
|
||||
# Return to previous branch.
|
||||
maybedryrun "${dry_run}" git checkout -
|
||||
if ((create_pr_stash)); then
|
||||
maybedryrun "${dry_run}" git stash pop
|
||||
fi
|
||||
|
||||
# Push the branch so it's available for gh to create the PR.
|
||||
maybedryrun "${dry_run}" git push -u "${remote}" "${pr_branch}"
|
||||
|
||||
log "Creating pull request..."
|
||||
maybedryrun "${dry_run}" gh pr create \
|
||||
--assignee "${pr_review_assignee}" \
|
||||
--reviewer "${pr_review_reviewer}" \
|
||||
--base "${branch}" \
|
||||
--head "${pr_branch}" \
|
||||
--title "${title}" \
|
||||
--body "${body}"
|
||||
fi
|
||||
|
||||
if ((dry_run)); then
|
||||
# We can't watch the release.yaml workflow if we're in dry-run mode.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log
|
||||
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 10 minutes (60*10/60) for the job to start.
|
||||
for _ in $(seq 1 60); do
|
||||
output="$(
|
||||
# Output:
|
||||
# 3886828508
|
||||
# in_progress
|
||||
gh run list -w release.yaml \
|
||||
--limit 1 \
|
||||
--json status,databaseId \
|
||||
--jq '.[] | (.databaseId | tostring), .status'
|
||||
)"
|
||||
mapfile -t run <<<"$output"
|
||||
if [[ ${run[1]} != "in_progress" ]]; then
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
gh run watch --exit-status "${run[0]}"
|
||||
exit 0
|
||||
done
|
||||
|
||||
error "Waiting for job to start timed out."
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
exec go run ./scripts/releaser "$@"
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// commitEntry represents a single non-merge commit.
|
||||
type commitEntry struct {
|
||||
SHA string
|
||||
FullSHA string
|
||||
Title string
|
||||
PRCount int // 0 if no PR number found
|
||||
Timestamp int64
|
||||
}
|
||||
|
||||
var prNumRe = regexp.MustCompile(`\(#(\d+)\)`)
|
||||
|
||||
// cherryPickPRRe matches cherry-pick bot titles like
|
||||
// "chore: foo bar (cherry-pick #42) (#43)".
|
||||
var cherryPickPRRe = regexp.MustCompile(`\(cherry-pick #(\d+)\)\s*\(#\d+\)$`)
|
||||
|
||||
// commitLog returns non-merge commits in the given range, filtering
|
||||
// out left-side commits (already in the base) and deduplicating
|
||||
// cherry-picks using git's --cherry-mark.
|
||||
func commitLog(commitRange string) ([]commitEntry, error) {
|
||||
// Use --left-right --cherry-mark to identify equivalent
|
||||
// (cherry-picked) commits and left-side-only commits.
|
||||
out, err := gitOutput("log", "--no-merges", "--left-right", "--cherry-mark",
|
||||
"--pretty=format:%m %ct %h %H %s", commitRange)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Collect cherry-pick equivalent commits (marked with '=') so
|
||||
// we can skip duplicates. We keep only the right-side version.
|
||||
seen := make(map[string]bool)
|
||||
|
||||
var entries []commitEntry
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Format: %m %ct %h %H %s
|
||||
// mark timestamp shortSHA fullSHA title...
|
||||
parts := strings.SplitN(line, " ", 5)
|
||||
if len(parts) < 5 {
|
||||
continue
|
||||
}
|
||||
mark := parts[0]
|
||||
ts, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||
shortSHA := parts[2]
|
||||
fullSHA := parts[3]
|
||||
title := parts[4]
|
||||
|
||||
// Skip left-side commits (already in the old version).
|
||||
if mark == "<" {
|
||||
continue
|
||||
}
|
||||
// Skip cherry-pick equivalents that we've already seen
|
||||
// (marked '=' by --cherry-mark).
|
||||
if mark == "=" {
|
||||
if seen[title] {
|
||||
continue
|
||||
}
|
||||
seen[title] = true
|
||||
}
|
||||
|
||||
// Normalize cherry-pick bot titles:
|
||||
// "chore: foo (cherry-pick #42) (#43)" → "chore: foo (#42)"
|
||||
if m := cherryPickPRRe.FindStringSubmatch(title); m != nil {
|
||||
title = title[:cherryPickPRRe.FindStringIndex(title)[0]] + "(#" + m[1] + ")"
|
||||
}
|
||||
|
||||
e := commitEntry{
|
||||
SHA: shortSHA,
|
||||
FullSHA: fullSHA,
|
||||
Title: title,
|
||||
Timestamp: ts,
|
||||
}
|
||||
if m := prNumRe.FindStringSubmatch(e.Title); m != nil {
|
||||
e.PRCount, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
// Sort by conventional commit prefix, then by timestamp
|
||||
// (matching the bash script's sort -k3,3 -k1,1n).
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
pi := commitSortPrefix(entries[i].Title)
|
||||
pj := commitSortPrefix(entries[j].Title)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
return entries[i].Timestamp < entries[j].Timestamp
|
||||
})
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// commitSortPrefix extracts the first word of a title for sorting.
|
||||
func commitSortPrefix(title string) string {
|
||||
idx := strings.IndexAny(title, " (:")
|
||||
if idx < 0 {
|
||||
return title
|
||||
}
|
||||
return title[:idx]
|
||||
}
|
||||
|
||||
// humanizedAreas maps conventional commit scopes to human-readable area
|
||||
// names. Order matters: more specific prefixes must come first so that
|
||||
// the first partial match wins.
|
||||
var humanizedAreas = []struct {
|
||||
Prefix string
|
||||
Area string
|
||||
}{
|
||||
{"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"},
|
||||
}
|
||||
|
||||
// conventionalPrefixRe extracts prefix, scope, and rest from a
|
||||
// conventional commit title. Does NOT match breaking "!" suffix —
|
||||
// those titles are left as-is (matching bash behavior).
|
||||
var conventionalPrefixRe = regexp.MustCompile(`^([a-z]+)(\((.+)\))?:\s*(.*)$`)
|
||||
|
||||
// humanizeTitle converts a conventional commit title to a
|
||||
// human-readable form, e.g. "feat(site): add bar" → "Dashboard: Add bar".
|
||||
func humanizeTitle(title string) string {
|
||||
m := conventionalPrefixRe.FindStringSubmatch(title)
|
||||
if m == nil {
|
||||
return title
|
||||
}
|
||||
scope := m[3] // may be empty
|
||||
rest := m[4]
|
||||
if rest == "" {
|
||||
return title
|
||||
}
|
||||
// Capitalize the first letter of the rest.
|
||||
rest = strings.ToUpper(rest[:1]) + rest[1:]
|
||||
|
||||
if scope == "" {
|
||||
return rest
|
||||
}
|
||||
|
||||
// Look up scope in humanizedAreas (first partial match wins).
|
||||
for _, ha := range humanizedAreas {
|
||||
if strings.HasPrefix(scope, ha.Prefix) {
|
||||
return ha.Area + ": " + rest
|
||||
}
|
||||
}
|
||||
// Scope not found in map — return as-is.
|
||||
return title
|
||||
}
|
||||
|
||||
// breakingCommitRe matches conventional commit "!:" breaking changes.
|
||||
var breakingCommitRe = regexp.MustCompile(`^[a-zA-Z]+(\(.+\))?!:`)
|
||||
|
||||
// categorizeCommit determines the release note section for a commit.
|
||||
// The priority order matches the bash script: breaking title first,
|
||||
// then labels (breaking, security, experimental), then prefix.
|
||||
func categorizeCommit(title string, labels []string) string {
|
||||
// Check breaking title first (matches bash behavior).
|
||||
if breakingCommitRe.MatchString(title) {
|
||||
return "breaking"
|
||||
}
|
||||
|
||||
// Label-based categorization.
|
||||
for _, l := range labels {
|
||||
if l == "release/breaking" {
|
||||
return "breaking"
|
||||
}
|
||||
if l == "security" {
|
||||
return "security"
|
||||
}
|
||||
if l == "release/experimental" {
|
||||
return "experimental"
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the conventional commit prefix (e.g. "feat", "fix(scope)").
|
||||
prefixRe := regexp.MustCompile(`^([a-z]+)(\(.+\))?[!]?:`)
|
||||
m := prefixRe.FindStringSubmatch(title)
|
||||
if m == nil {
|
||||
return "other"
|
||||
}
|
||||
|
||||
validPrefixes := []string{
|
||||
"feat", "fix", "docs", "refactor", "perf",
|
||||
"test", "build", "ci", "chore", "revert",
|
||||
}
|
||||
for _, p := range validPrefixes {
|
||||
if m[1] == p {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
const (
|
||||
calendarStartMarker = "<!-- RELEASE_CALENDAR_START -->"
|
||||
calendarEndMarker = "<!-- RELEASE_CALENDAR_END -->"
|
||||
|
||||
releasesFile = "docs/install/releases/index.md"
|
||||
kubernetesFile = "docs/install/kubernetes.md"
|
||||
rancherFile = "docs/install/rancher.md"
|
||||
changelogURLFmt = "https://coder.com/changelog/coder-%d-%d"
|
||||
releaseTagURLFmt = "https://github.com/coder/coder/releases/tag/%s"
|
||||
)
|
||||
|
||||
// calendarRow represents one row in the release calendar table.
|
||||
type calendarRow struct {
|
||||
// ReleaseName is the display name, e.g. "2.30" or
|
||||
// "[2.30](https://...)".
|
||||
ReleaseName string
|
||||
// Major and Minor parsed from the release name.
|
||||
Major int
|
||||
Minor int
|
||||
// ReleaseDate as displayed, e.g. "February 03, 2026".
|
||||
ReleaseDate string
|
||||
// Status like "Mainline", "Stable", "Not Supported", etc.
|
||||
Status string
|
||||
// LatestRelease as displayed, e.g.
|
||||
// "[v2.30.0](https://...)".
|
||||
LatestRelease string
|
||||
}
|
||||
|
||||
var autoversionPragmaRe = regexp.MustCompile(
|
||||
`<!-- ?autoversion\(([^)]+)\): ?"([^"]+)" ?-->`,
|
||||
)
|
||||
|
||||
// parseCalendarTable extracts calendar rows from the markdown
|
||||
// between the start and end markers. Returns the rows and the
|
||||
// column widths for re-rendering.
|
||||
func parseCalendarTable(content string) ([]calendarRow, error) {
|
||||
startIdx := strings.Index(content, calendarStartMarker)
|
||||
endIdx := strings.Index(content, calendarEndMarker)
|
||||
if startIdx == -1 || endIdx == -1 {
|
||||
return nil, xerrors.New("calendar markers not found")
|
||||
}
|
||||
|
||||
tableContent := content[startIdx+len(calendarStartMarker) : endIdx]
|
||||
lines := strings.Split(strings.TrimSpace(tableContent), "\n")
|
||||
|
||||
var rows []calendarRow
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Skip header and separator lines.
|
||||
if strings.HasPrefix(line, "| Release") ||
|
||||
strings.HasPrefix(line, "|---") ||
|
||||
strings.HasPrefix(line, "|-") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "|") {
|
||||
continue
|
||||
}
|
||||
|
||||
cols := strings.Split(line, "|")
|
||||
// Split on "|" gives empty first and last elements.
|
||||
if len(cols) < 5 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(cols[1])
|
||||
date := strings.TrimSpace(cols[2])
|
||||
status := strings.TrimSpace(cols[3])
|
||||
latest := strings.TrimSpace(cols[4])
|
||||
|
||||
major, minor := parseReleaseName(name)
|
||||
rows = append(rows, calendarRow{
|
||||
ReleaseName: name,
|
||||
Major: major,
|
||||
Minor: minor,
|
||||
ReleaseDate: date,
|
||||
Status: status,
|
||||
LatestRelease: latest,
|
||||
})
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return nil, xerrors.New("no calendar rows found")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// parseReleaseName extracts major.minor from a release name
|
||||
// like "2.30" or "[2.30](https://...)".
|
||||
func parseReleaseName(name string) (major, minor int) {
|
||||
// Strip markdown link if present.
|
||||
re := regexp.MustCompile(`\[(\d+\.\d+)\]`)
|
||||
if m := re.FindStringSubmatch(name); len(m) > 1 {
|
||||
name = m[1]
|
||||
}
|
||||
_, _ = fmt.Sscanf(name, "%d.%d", &major, &minor)
|
||||
return major, minor
|
||||
}
|
||||
|
||||
// renderCalendarTable renders the calendar rows as a markdown
|
||||
// table.
|
||||
func renderCalendarTable(rows []calendarRow) string {
|
||||
// Compute column widths.
|
||||
nameW, dateW, statusW, latestW := 12, 12, 6, 14
|
||||
for _, r := range rows {
|
||||
if len(r.ReleaseName) > nameW {
|
||||
nameW = len(r.ReleaseName)
|
||||
}
|
||||
if len(r.ReleaseDate) > dateW {
|
||||
dateW = len(r.ReleaseDate)
|
||||
}
|
||||
if len(r.Status) > statusW {
|
||||
statusW = len(r.Status)
|
||||
}
|
||||
if len(r.LatestRelease) > latestW {
|
||||
latestW = len(r.LatestRelease)
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
// Header.
|
||||
_, _ = fmt.Fprintf(&b, "| %-*s | %-*s | %-*s | %-*s |\n",
|
||||
nameW, "Release name",
|
||||
dateW, "Release Date",
|
||||
statusW, "Status",
|
||||
latestW, "Latest Release")
|
||||
// Separator.
|
||||
_, _ = fmt.Fprintf(&b, "|%s|%s|%s|%s|\n",
|
||||
strings.Repeat("-", nameW+1),
|
||||
strings.Repeat("-", dateW+2),
|
||||
strings.Repeat("-", statusW+2),
|
||||
strings.Repeat("-", latestW+2))
|
||||
// Data rows.
|
||||
for _, r := range rows {
|
||||
_, _ = fmt.Fprintf(&b, "| %-*s | %-*s | %-*s | %-*s |\n",
|
||||
nameW, r.ReleaseName,
|
||||
dateW, r.ReleaseDate,
|
||||
statusW, r.Status,
|
||||
latestW, r.LatestRelease)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// updateCalendar modifies the calendar rows based on the new
|
||||
// release version and channel.
|
||||
func updateCalendar(
|
||||
rows []calendarRow,
|
||||
newVer version,
|
||||
channel string,
|
||||
) []calendarRow {
|
||||
// For any release, update the "Latest Release" for the
|
||||
// matching major.minor row.
|
||||
for i, r := range rows {
|
||||
if r.Major == newVer.Major && r.Minor == newVer.Minor {
|
||||
rows[i].LatestRelease = fmt.Sprintf(
|
||||
"[v%s](%s)",
|
||||
newVer.String(),
|
||||
fmt.Sprintf(releaseTagURLFmt, newVer.String()),
|
||||
)
|
||||
// If this row was "Not Released", update it.
|
||||
if r.Status == "Not Released" {
|
||||
rows[i].Status = "Mainline"
|
||||
rows[i].ReleaseDate = time.Now().Format("January 02, 2006")
|
||||
rows[i].ReleaseName = fmt.Sprintf(
|
||||
"[%d.%d](%s)",
|
||||
newVer.Major, newVer.Minor,
|
||||
fmt.Sprintf(changelogURLFmt, newVer.Major, newVer.Minor),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For patch releases, we only update Latest Release — done
|
||||
// above.
|
||||
if newVer.Patch > 0 {
|
||||
return rows
|
||||
}
|
||||
|
||||
// For new mainline releases (patch == 0), apply status
|
||||
// transitions.
|
||||
if channel == "mainline" {
|
||||
for i, r := range rows {
|
||||
switch {
|
||||
case r.Major == newVer.Major && r.Minor == newVer.Minor:
|
||||
// Already handled above.
|
||||
continue
|
||||
case r.Status == "Mainline":
|
||||
rows[i].Status = "Stable"
|
||||
case strings.Contains(r.Status, "Stable"):
|
||||
// "Stable", "Stable + ESR" → Security Support.
|
||||
rows[i].Status = "Security Support"
|
||||
case r.Status == "Security Support":
|
||||
rows[i].Status = "Not Supported"
|
||||
}
|
||||
}
|
||||
|
||||
// Add "Not Released" row for the next minor.
|
||||
nextMinor := newVer.Minor + 1
|
||||
hasNext := false
|
||||
for _, r := range rows {
|
||||
if r.Major == newVer.Major && r.Minor == nextMinor {
|
||||
hasNext = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNext {
|
||||
rows = append(rows, calendarRow{
|
||||
ReleaseName: fmt.Sprintf("%d.%d", newVer.Major, nextMinor),
|
||||
Major: newVer.Major,
|
||||
Minor: nextMinor,
|
||||
ReleaseDate: "",
|
||||
Status: "Not Released",
|
||||
LatestRelease: "N/A",
|
||||
})
|
||||
}
|
||||
|
||||
// Trim oldest "Not Supported" rows to keep roughly
|
||||
// the same number of rows. We allow up to the
|
||||
// current count + 1 (for the new "Not Released"
|
||||
// row), then trim.
|
||||
rows = trimOldestNotSupported(rows)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// trimOldestNotSupported removes "Not Supported" rows from the
|
||||
// start until we have at most 8 rows total, keeping at least
|
||||
// one "Not Supported" row if any exist.
|
||||
func trimOldestNotSupported(rows []calendarRow) []calendarRow {
|
||||
const maxRows = 8
|
||||
for len(rows) > maxRows {
|
||||
// Find the first "Not Supported" row.
|
||||
found := -1
|
||||
for i, r := range rows {
|
||||
if r.Status == "Not Supported" {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == -1 {
|
||||
break
|
||||
}
|
||||
// Count how many "Not Supported" rows we have.
|
||||
nsCount := 0
|
||||
for _, r := range rows {
|
||||
if r.Status == "Not Supported" {
|
||||
nsCount++
|
||||
}
|
||||
}
|
||||
// Keep at least one.
|
||||
if nsCount <= 1 {
|
||||
break
|
||||
}
|
||||
rows = append(rows[:found], rows[found+1:]...)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// updateCalendarFile reads the releases index.md, updates the
|
||||
// calendar table, and writes it back.
|
||||
func updateCalendarFile(
|
||||
repoRoot string,
|
||||
newVer version,
|
||||
channel string,
|
||||
) error {
|
||||
path := filepath.Join(repoRoot, releasesFile)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading %s: %w", releasesFile, err)
|
||||
}
|
||||
|
||||
rows, err := parseCalendarTable(string(content))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parsing calendar: %w", err)
|
||||
}
|
||||
|
||||
rows = updateCalendar(rows, newVer, channel)
|
||||
newTable := renderCalendarTable(rows)
|
||||
|
||||
// Replace the content between markers.
|
||||
s := string(content)
|
||||
startIdx := strings.Index(s, calendarStartMarker)
|
||||
endIdx := strings.Index(s, calendarEndMarker)
|
||||
updated := s[:startIdx+len(calendarStartMarker)] +
|
||||
"\n" + newTable +
|
||||
s[endIdx:]
|
||||
|
||||
//nolint:gosec // File permissions match the original.
|
||||
return os.WriteFile(path, []byte(updated), 0o644)
|
||||
}
|
||||
|
||||
// updateAutoversionFile reads a markdown file and replaces
|
||||
// version strings in lines following autoversion pragmas for
|
||||
// the given channel.
|
||||
func updateAutoversionFile(path, channel, newVer string) error {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
changed := false
|
||||
|
||||
for i, line := range lines {
|
||||
m := autoversionPragmaRe.FindStringSubmatch(line)
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
pragmaChannel := m[1]
|
||||
pattern := m[2]
|
||||
|
||||
if pragmaChannel != channel {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build regex from the pattern by replacing
|
||||
// [version] with a capture group.
|
||||
escaped := regexp.QuoteMeta(pattern)
|
||||
reStr := strings.ReplaceAll(
|
||||
escaped,
|
||||
regexp.QuoteMeta("[version]"),
|
||||
`(\d+\.\d+\.\d+)`,
|
||||
)
|
||||
re, err := regexp.Compile(reStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Search the next few lines for a match.
|
||||
for j := i + 1; j < len(lines) && j <= i+5; j++ {
|
||||
if loc := re.FindStringSubmatchIndex(lines[j]); loc != nil {
|
||||
// loc[2]:loc[3] is the version capture
|
||||
// group.
|
||||
lines[j] = lines[j][:loc[2]] + newVer + lines[j][loc[3]:]
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:gosec // File permissions match the original.
|
||||
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
|
||||
}
|
||||
|
||||
// updateRancherFile updates the version strings in rancher.md.
|
||||
func updateRancherFile(path, channel, newVer string) error {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
s := string(content)
|
||||
|
||||
switch channel {
|
||||
case "mainline":
|
||||
// Match: - **Mainline**: `X.Y.Z`
|
||||
re := regexp.MustCompile(
|
||||
`(\*\*Mainline\*\*: ` + "`)" + `\d+\.\d+\.\d+` + "(`)",
|
||||
)
|
||||
s = re.ReplaceAllString(s, "${1}"+newVer+"${2}")
|
||||
case "stable":
|
||||
re := regexp.MustCompile(
|
||||
`(\*\*Stable\*\*: ` + "`)" + `\d+\.\d+\.\d+` + "(`)",
|
||||
)
|
||||
s = re.ReplaceAllString(s, "${1}"+newVer+"${2}")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:gosec // File permissions match the original.
|
||||
return os.WriteFile(path, []byte(s), 0o644)
|
||||
}
|
||||
|
||||
// updateReleaseDocs updates all release-related docs files and
|
||||
// creates a PR with the changes.
|
||||
//
|
||||
//nolint:revive // dryRun flag is needed to control PR creation behavior.
|
||||
func updateReleaseDocs(
|
||||
inv *serpent.Invocation,
|
||||
newVer version,
|
||||
channel string,
|
||||
dryRun bool,
|
||||
) error {
|
||||
w := inv.Stderr
|
||||
|
||||
// Find the repo root (where .git is).
|
||||
repoRoot, err := gitOutput("rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("finding repo root: %w", err)
|
||||
}
|
||||
|
||||
verStr := fmt.Sprintf("%d.%d.%d", newVer.Major, newVer.Minor, newVer.Patch)
|
||||
vTag := "v" + verStr
|
||||
branchName := fmt.Sprintf("docs/update-release-%s", vTag)
|
||||
|
||||
infof(w, "Updating release docs for %s (channel: %s)...", vTag, channel)
|
||||
fmt.Fprintln(w)
|
||||
|
||||
if dryRun {
|
||||
_, _ = fmt.Fprintf(w, "[DRYRUN] would update %s\n", releasesFile)
|
||||
_, _ = fmt.Fprintf(w, "[DRYRUN] would update %s\n", kubernetesFile)
|
||||
_, _ = fmt.Fprintf(w, "[DRYRUN] would update %s\n", rancherFile)
|
||||
_, _ = fmt.Fprintf(w, "[DRYRUN] would create branch %s\n", branchName)
|
||||
_, _ = fmt.Fprintf(w, "[DRYRUN] would create PR: chore(docs): update release docs for %s\n", vTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a new branch from main.
|
||||
if err := gitRun("checkout", "-b", branchName, "origin/main"); err != nil {
|
||||
return xerrors.Errorf("creating branch: %w", err)
|
||||
}
|
||||
|
||||
// Update the files.
|
||||
if err := updateCalendarFile(repoRoot, newVer, channel); err != nil {
|
||||
return xerrors.Errorf("updating calendar: %w", err)
|
||||
}
|
||||
successf(w, "Updated %s", releasesFile)
|
||||
|
||||
k8sPath := filepath.Join(repoRoot, kubernetesFile)
|
||||
if err := updateAutoversionFile(k8sPath, channel, verStr); err != nil {
|
||||
return xerrors.Errorf("updating kubernetes.md: %w", err)
|
||||
}
|
||||
successf(w, "Updated %s", kubernetesFile)
|
||||
|
||||
rancherPath := filepath.Join(repoRoot, rancherFile)
|
||||
if err := updateRancherFile(rancherPath, channel, verStr); err != nil {
|
||||
return xerrors.Errorf("updating rancher.md: %w", err)
|
||||
}
|
||||
successf(w, "Updated %s", rancherFile)
|
||||
|
||||
// Stage and commit.
|
||||
if err := gitRun("add",
|
||||
filepath.Join(repoRoot, releasesFile),
|
||||
k8sPath,
|
||||
rancherPath,
|
||||
); err != nil {
|
||||
return xerrors.Errorf("staging files: %w", err)
|
||||
}
|
||||
|
||||
commitMsg := fmt.Sprintf("chore(docs): update release docs for %s", vTag)
|
||||
if err := gitRun("commit", "-m", commitMsg); err != nil {
|
||||
return xerrors.Errorf("committing: %w", err)
|
||||
}
|
||||
|
||||
// Push and create PR.
|
||||
if err := gitRun("push", "origin", branchName); err != nil {
|
||||
return xerrors.Errorf("pushing branch: %w", err)
|
||||
}
|
||||
|
||||
prTitle := commitMsg
|
||||
prBody := fmt.Sprintf("Automated docs update for %s release.\n\nCreated by `releasetui`.", vTag)
|
||||
|
||||
out, err := ghOutput("pr", "create",
|
||||
"--repo", owner+"/"+repo,
|
||||
"--title", prTitle,
|
||||
"--body", prBody,
|
||||
"--base", "main",
|
||||
"--head", branchName,
|
||||
)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("creating PR: %w", err)
|
||||
}
|
||||
|
||||
prURL := strings.TrimSpace(out)
|
||||
successf(w, "Created PR: %s", prURL)
|
||||
fmt.Fprintln(w)
|
||||
infof(w, "Review and merge the PR to complete the docs update.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// promptAndUpdateDocs asks the user if they want to create a
|
||||
// docs update PR and does so if confirmed.
|
||||
func promptAndUpdateDocs(
|
||||
inv *serpent.Invocation,
|
||||
newVer version,
|
||||
channel string,
|
||||
dryRun bool,
|
||||
) {
|
||||
w := inv.Stderr
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(),
|
||||
"Next step: create a PR updating release docs "+
|
||||
"(calendar, helm versions, rancher)."))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
if err := confirmWithDefault(inv, "Create docs update PR?", cliui.ConfirmYes); err != nil {
|
||||
infof(w, "Skipped docs update. You can update them manually.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateReleaseDocs(inv, newVer, channel, dryRun); err != nil {
|
||||
warnf(w, "Failed to create docs PR: %v", err)
|
||||
warnf(w, "You'll need to update release docs manually.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// ReleaseExecutor handles dangerous write/mutating operations
|
||||
// that should be skipped in dry-run mode. Only actions that
|
||||
// modify the git repo or trigger external side effects belong
|
||||
// here. Safe operations (file writes, fetches, editor) are
|
||||
// called directly.
|
||||
type ReleaseExecutor interface {
|
||||
// CreateTag creates an annotated (optionally signed) git tag.
|
||||
CreateTag(ctx context.Context, tag, ref, message string, sign bool) error
|
||||
// PushTag pushes a tag to the origin remote.
|
||||
PushTag(ctx context.Context, tag string) error
|
||||
// TriggerWorkflow dispatches the release.yaml GitHub Actions
|
||||
// workflow with the given inputs.
|
||||
TriggerWorkflow(ctx context.Context, ref, channel, releaseNotes string) error
|
||||
}
|
||||
|
||||
// liveExecutor performs real operations.
|
||||
type liveExecutor struct{}
|
||||
|
||||
//nolint:revive // sign flag is part of the ReleaseExecutor interface contract.
|
||||
func (e *liveExecutor) CreateTag(_ context.Context, tag, ref, message string, sign bool) error {
|
||||
args := []string{"tag", "-a"}
|
||||
if sign {
|
||||
args = append(args, "-s")
|
||||
}
|
||||
args = append(args, tag, "-m", message, ref)
|
||||
return gitRun(args...)
|
||||
}
|
||||
|
||||
func (*liveExecutor) PushTag(_ context.Context, tag string) error {
|
||||
return gitRun("push", "origin", tag)
|
||||
}
|
||||
|
||||
func (*liveExecutor) TriggerWorkflow(_ context.Context, ref, channel, releaseNotes string) error {
|
||||
payload := map[string]string{
|
||||
"dry_run": "false",
|
||||
"release_channel": channel,
|
||||
"release_notes": releaseNotes,
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("marshaling workflow payload: %w", err)
|
||||
}
|
||||
cmd := exec.Command("gh", "workflow", "run", "release.yaml",
|
||||
"--repo", owner+"/"+repo,
|
||||
"--ref", ref,
|
||||
"--json",
|
||||
)
|
||||
cmd.Stdin = strings.NewReader(string(payloadJSON))
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// dryRunExecutor prints what would happen without doing it.
|
||||
type dryRunExecutor struct {
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
//nolint:revive // sign flag is part of the ReleaseExecutor interface contract.
|
||||
func (e *dryRunExecutor) CreateTag(_ context.Context, tag, ref, message string, sign bool) error {
|
||||
signFlag := ""
|
||||
if sign {
|
||||
signFlag = "-s "
|
||||
}
|
||||
_, _ = fmt.Fprintf(e.w, "[DRYRUN] would run: git tag %s-a %s -m %q %s\n", signFlag, tag, message, ref)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *dryRunExecutor) PushTag(_ context.Context, tag string) error {
|
||||
_, _ = fmt.Fprintf(e.w, "[DRYRUN] would run: git push origin %s\n", tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *dryRunExecutor) TriggerWorkflow(_ context.Context, ref, channel, _ string) error {
|
||||
_, _ = fmt.Fprintf(e.w, "[DRYRUN] would trigger release.yaml workflow (ref=%s, channel=%s)\n", ref, channel)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// gitOutput runs a read-only git command and returns trimmed stdout.
|
||||
func gitOutput(args ...string) (string, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", exitErr
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// gitRun runs a git command with stdout/stderr connected to the
|
||||
// terminal.
|
||||
func gitRun(args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ghOutput runs a gh CLI command and returns trimmed stdout.
|
||||
func ghOutput(args ...string) (string, error) {
|
||||
cmd := exec.Command("gh", args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", exitErr
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// checkGHAuth verifies that the gh CLI is installed and
|
||||
// authenticated. Returns true if gh is available.
|
||||
func checkGHAuth() bool {
|
||||
cmd := exec.Command("gh", "auth", "status")
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
// ghPR is a minimal pull request representation parsed from gh CLI
|
||||
// JSON output.
|
||||
type ghPR struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Labels []string
|
||||
}
|
||||
|
||||
// ghListOpenPRs returns open PRs targeting the given branch via
|
||||
// the gh CLI.
|
||||
func ghListOpenPRs(branch string) ([]ghPR, error) {
|
||||
out, err := ghOutput("pr", "list",
|
||||
"--repo", owner+"/"+repo,
|
||||
"--base", branch,
|
||||
"--state", "open",
|
||||
"--json", "number,title,author",
|
||||
"--jq", `.[] | "\(.number)\t\(.title)\t\(.author.login)"`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var prs []ghPR
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
parts := strings.SplitN(line, "\t", 3)
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
num, _ := strconv.Atoi(parts[0])
|
||||
prs = append(prs, ghPR{
|
||||
Number: num,
|
||||
Title: parts[1],
|
||||
Author: parts[2],
|
||||
})
|
||||
}
|
||||
return prs, nil
|
||||
}
|
||||
|
||||
// ghListPRsWithLabel returns merged PRs targeting the given branch
|
||||
// that have a specific label.
|
||||
func ghListPRsWithLabel(branch, label string) ([]ghPR, error) {
|
||||
out, err := ghOutput("pr", "list",
|
||||
"--repo", owner+"/"+repo,
|
||||
"--base", branch,
|
||||
"--state", "merged",
|
||||
"--label", label,
|
||||
"--json", "number,title",
|
||||
"--jq", `.[] | "\(.number)\t\(.title)"`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var prs []ghPR
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
parts := strings.SplitN(line, "\t", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
num, _ := strconv.Atoi(parts[0])
|
||||
prs = append(prs, ghPR{Number: num, Title: parts[1]})
|
||||
}
|
||||
return prs, nil
|
||||
}
|
||||
|
||||
// prMetadata holds labels and author for a merged PR.
|
||||
type prMetadata struct {
|
||||
Labels []string
|
||||
Author string
|
||||
}
|
||||
|
||||
// prMetadataMaps holds PR metadata indexed by both merge-commit SHA
|
||||
// and PR number. On release branches, commits are cherry-picked so
|
||||
// their SHA differs from the original merge commit on main. The PR
|
||||
// number (preserved in the commit title) provides a fallback lookup.
|
||||
type prMetadataMaps struct {
|
||||
bySHA map[string]prMetadata
|
||||
byNumber map[int]prMetadata
|
||||
}
|
||||
|
||||
// lookupCommit returns PR metadata for a commit, trying the full SHA
|
||||
// first and falling back to PR number for cherry-picked commits.
|
||||
func (m *prMetadataMaps) lookupCommit(fullSHA string, prNumber int) prMetadata {
|
||||
if meta, ok := m.bySHA[fullSHA]; ok {
|
||||
return meta
|
||||
}
|
||||
if prNumber > 0 {
|
||||
return m.byNumber[prNumber]
|
||||
}
|
||||
return prMetadata{}
|
||||
}
|
||||
|
||||
// ghBuildPRMetadataMap returns PR metadata indexed by both
|
||||
// merge-commit SHA and PR number for merged PRs targeting main.
|
||||
// This matches the bash script's approach of querying --base main
|
||||
// with a date filter based on the oldest commit in the range.
|
||||
func ghBuildPRMetadataMap(commits []commitEntry) (*prMetadataMaps, error) {
|
||||
empty := &prMetadataMaps{
|
||||
bySHA: make(map[string]prMetadata),
|
||||
byNumber: make(map[int]prMetadata),
|
||||
}
|
||||
if len(commits) == 0 {
|
||||
return empty, nil
|
||||
}
|
||||
// Find the earliest commit timestamp to scope the PR query.
|
||||
earliest := commits[0].Timestamp
|
||||
for _, c := range commits[1:] {
|
||||
if c.Timestamp < earliest {
|
||||
earliest = c.Timestamp
|
||||
}
|
||||
}
|
||||
lookbackDate := time.Unix(earliest, 0).Format("2006-01-02")
|
||||
|
||||
out, err := ghOutput("pr", "list",
|
||||
"--repo", owner+"/"+repo,
|
||||
"--base", "main",
|
||||
"--state", "merged",
|
||||
"--limit", "10000",
|
||||
"--search", "merged:>="+lookbackDate,
|
||||
"--json", "number,mergeCommit,labels,author",
|
||||
"--jq", `.[] | "\(.number)\t\(.mergeCommit.oid)\t\(.author.login)\t\([.labels[].name] | join(","))"`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == "" {
|
||||
return empty, nil
|
||||
}
|
||||
result := &prMetadataMaps{
|
||||
bySHA: make(map[string]prMetadata),
|
||||
byNumber: make(map[int]prMetadata),
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
parts := strings.SplitN(line, "\t", 4)
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
num, _ := strconv.Atoi(parts[0])
|
||||
sha := parts[1]
|
||||
author := parts[2]
|
||||
var labels []string
|
||||
if parts[3] != "" {
|
||||
labels = strings.Split(parts[3], ",")
|
||||
sort.Strings(labels)
|
||||
}
|
||||
meta := prMetadata{
|
||||
Labels: labels,
|
||||
Author: author,
|
||||
}
|
||||
result.bySHA[sha] = meta
|
||||
if num > 0 {
|
||||
result.byNumber[num] = meta
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
const (
|
||||
owner = "coder"
|
||||
repo = "coder"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var dryRun bool
|
||||
cmd := &serpent.Command{
|
||||
Use: "releaser",
|
||||
Short: "Interactive release tagging for coder/coder.",
|
||||
Long: "Run this from a release branch (release/X.Y). The tool detects the branch, infers the next version, and walks you through tagging, pushing, and triggering the release workflow.",
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
Name: "dry-run",
|
||||
Flag: "dry-run",
|
||||
Description: "Print write commands instead of executing them.",
|
||||
Value: serpent.BoolOf(&dryRun),
|
||||
},
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
w := inv.Stderr
|
||||
|
||||
// --- Check dependencies ---
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return xerrors.New("git is required but not found in PATH")
|
||||
}
|
||||
|
||||
// --- Check GPG signing ---
|
||||
signingKey, _ := gitOutput("config", "--get", "user.signingkey")
|
||||
gpgFormat, _ := gitOutput("config", "--get", "gpg.format")
|
||||
gpgConfigured := signingKey != "" || gpgFormat != ""
|
||||
if !gpgConfigured {
|
||||
warnf(w, "GPG signing is not configured. Tags will be unsigned — there will be no way to verify who pushed the tag.")
|
||||
_, _ = fmt.Fprintf(w, " To fix: set git config user.signingkey or gpg.format\n")
|
||||
if err := confirmWithDefault(inv, "Continue without signing?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// --- Check gh CLI auth ---
|
||||
ghAvailable := checkGHAuth()
|
||||
if !ghAvailable {
|
||||
warnf(w, "gh CLI is not available or not authenticated.")
|
||||
infof(w, "Continuing without GitHub features (PR checks, label lookups, workflow trigger).")
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// --- Wire up executor ---
|
||||
var executor ReleaseExecutor
|
||||
if dryRun {
|
||||
outputPrefix = "[DRYRUN] "
|
||||
executor = &dryRunExecutor{w: w}
|
||||
} else {
|
||||
executor = &liveExecutor{}
|
||||
}
|
||||
|
||||
return runRelease(ctx, inv, executor, ghAvailable, gpgConfigured, dryRun)
|
||||
},
|
||||
}
|
||||
|
||||
err := cmd.Invoke().WithOS().Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, cliui.ErrCanceled) {
|
||||
os.Exit(1)
|
||||
}
|
||||
// Unwrap serpent's "running command ..." wrapper to
|
||||
// keep output clean.
|
||||
var runErr *serpent.RunCommandError
|
||||
if errors.As(err, &runErr) {
|
||||
err = runErr.Err
|
||||
}
|
||||
pretty.Fprintf(os.Stderr, cliui.DefaultStyles.Error, "Error: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
//nolint:revive // Long function is fine for a sequential release flow.
|
||||
func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseExecutor, ghAvailable, gpgConfigured, dryRun bool) error {
|
||||
w := inv.Stderr
|
||||
|
||||
// --- Release landscape ---
|
||||
infof(w, "Checking current releases...")
|
||||
allTags, err := allSemverTags()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listing tags: %w", err)
|
||||
}
|
||||
|
||||
var latestMainline *version
|
||||
if len(allTags) > 0 {
|
||||
v := allTags[0]
|
||||
latestMainline = &v
|
||||
}
|
||||
|
||||
stableMinor := -1
|
||||
latestStableStr := "(unknown)"
|
||||
if latestMainline != nil {
|
||||
stableMinor = latestMainline.Minor - 1
|
||||
// Find highest tag in the stable minor series.
|
||||
for _, t := range allTags {
|
||||
if t.Major == latestMainline.Major && t.Minor == stableMinor {
|
||||
latestStableStr = t.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
if latestStableStr == "(unknown)" {
|
||||
latestStableStr = fmt.Sprintf("(none found for v%d.%d.x)", latestMainline.Major, stableMinor)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
mainlineStr := "(none)"
|
||||
if latestMainline != nil {
|
||||
mainlineStr = latestMainline.String()
|
||||
}
|
||||
fmt.Fprintf(w, " Latest mainline release: %s\n", pretty.Sprint(cliui.BoldFmt(), mainlineStr))
|
||||
fmt.Fprintf(w, " Latest stable release: %s\n", pretty.Sprint(cliui.BoldFmt(), latestStableStr))
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// --- Branch detection ---
|
||||
currentBranch, err := gitOutput("branch", "--show-current")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("detecting branch: %w", err)
|
||||
}
|
||||
|
||||
branchRe := regexp.MustCompile(`^release/(\d+)\.(\d+)$`)
|
||||
m := branchRe.FindStringSubmatch(currentBranch)
|
||||
if m == nil {
|
||||
warnf(w, "Current branch %q is not a release branch (release/X.Y).", currentBranch)
|
||||
branchInput, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Enter the release branch to use (e.g. release/2.21)",
|
||||
Validate: func(s string) error {
|
||||
if !branchRe.MatchString(s) {
|
||||
return xerrors.New("must be in format release/X.Y (e.g. release/2.21)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentBranch = branchInput
|
||||
m = branchRe.FindStringSubmatch(currentBranch)
|
||||
}
|
||||
branchMajor, _ := strconv.Atoi(m[1])
|
||||
branchMinor, _ := strconv.Atoi(m[2])
|
||||
successf(w, "Using release branch: %s", currentBranch)
|
||||
|
||||
// --- Fetch & sync check ---
|
||||
infof(w, "Fetching latest from origin...")
|
||||
if err := gitRun("fetch", "--quiet", "--tags", "origin", currentBranch); err != nil {
|
||||
return xerrors.Errorf("fetching: %w", err)
|
||||
}
|
||||
|
||||
localHead, err := gitOutput("rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolving HEAD: %w", err)
|
||||
}
|
||||
remoteHead, _ := gitOutput("rev-parse", "origin/"+currentBranch)
|
||||
|
||||
if remoteHead != "" && localHead != remoteHead {
|
||||
warnf(w, "Your local branch is not up to date with origin/%s.", currentBranch)
|
||||
fmt.Fprintf(w, " Local: %s\n", localHead[:12])
|
||||
fmt.Fprintf(w, " Remote: %s\n", remoteHead[:12])
|
||||
if err := confirmWithDefault(inv, "Continue anyway?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// --- Find previous version & suggest next ---
|
||||
mergedTags, err := mergedSemverTags()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listing merged tags: %w", err)
|
||||
}
|
||||
|
||||
// Find the latest tag matching this branch's major.minor.
|
||||
// Without this filter, tags from newer branches (e.g. v2.31.0)
|
||||
// that are reachable via merge history would be picked up
|
||||
// incorrectly on older release branches (e.g. release/2.30).
|
||||
var prevVersion *version
|
||||
for _, t := range mergedTags {
|
||||
if t.Major == branchMajor && t.Minor == branchMinor {
|
||||
v := t
|
||||
prevVersion = &v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var suggested version
|
||||
if prevVersion == nil {
|
||||
infof(w, "No previous release tag found on this branch.")
|
||||
suggested = version{Major: branchMajor, Minor: branchMinor, Patch: 0}
|
||||
} else {
|
||||
infof(w, "Previous release tag: %s", prevVersion.String())
|
||||
suggested = version{Major: prevVersion.Major, Minor: prevVersion.Minor, Patch: prevVersion.Patch + 1}
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// --- Version prompt ---
|
||||
versionInput, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Version to release",
|
||||
Default: suggested.String(),
|
||||
Validate: func(s string) error {
|
||||
if _, ok := parseVersion(s); !ok {
|
||||
return xerrors.New("must be in format vMAJOR.MINOR.PATCH (e.g. v2.31.1)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newVersion, _ := parseVersion(versionInput)
|
||||
|
||||
// Warn if version doesn't match branch.
|
||||
if newVersion.Major != branchMajor || newVersion.Minor != branchMinor {
|
||||
warnf(w, "Version %s does not match branch %s (expected v%d.%d.X).",
|
||||
newVersion, currentBranch, branchMajor, branchMinor)
|
||||
if err := confirmWithDefault(inv, "Continue anyway?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
infof(w, "=== Coder Release: %s ===", newVersion)
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// --- Check if tag already exists ---
|
||||
tagExists := false
|
||||
existingTag, _ := gitOutput("tag", "-l", newVersion.String())
|
||||
if existingTag != "" {
|
||||
tagExists = true
|
||||
warnf(w, "Tag '%s' already exists!", newVersion)
|
||||
if err := confirmWithDefault(inv, "This will skip tagging. Continue?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// --- Check open PRs ---
|
||||
// This runs before breaking changes so any last-minute merges
|
||||
// are caught by the subsequent checks.
|
||||
infof(w, "Checking for open PRs against %s...", currentBranch)
|
||||
var openPRs []ghPR
|
||||
if ghAvailable {
|
||||
openPRs, err = ghListOpenPRs(currentBranch)
|
||||
if err != nil {
|
||||
warnf(w, "Failed to check open PRs: %v", err)
|
||||
}
|
||||
} else {
|
||||
infof(w, "Skipping (no gh CLI).")
|
||||
}
|
||||
|
||||
if len(openPRs) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
warnf(w, "There are open PRs targeting %s that may need merging first:", currentBranch)
|
||||
fmt.Fprintln(w)
|
||||
for _, pr := range openPRs {
|
||||
fmt.Fprintf(w, " #%d %s (@%s)\n", pr.Number, pr.Title, pr.Author)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if err := confirmWithDefault(inv, "Continue without merging these?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
} else {
|
||||
successf(w, "No open PRs against %s.", currentBranch)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// --- Semver sanity checks ---
|
||||
if prevVersion != nil { //nolint:nestif // Sequential release checks are inherently nested.
|
||||
// Downgrade check.
|
||||
if prevVersion.GreaterThan(newVersion) {
|
||||
warnf(w, "Version DOWNGRADE detected: %s → %s.", prevVersion, newVersion)
|
||||
if err := confirmWithDefault(inv, "Continue?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Duplicate check.
|
||||
if prevVersion.Equal(newVersion) {
|
||||
warnf(w, "Version %s is the SAME as the previous tag %s.", newVersion, prevVersion)
|
||||
if err := confirmWithDefault(inv, "Continue?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Skipped patch check.
|
||||
if newVersion.Major == prevVersion.Major && newVersion.Minor == prevVersion.Minor {
|
||||
expectedPatch := prevVersion.Patch + 1
|
||||
if newVersion.Patch > expectedPatch {
|
||||
warnf(w, "Skipping patch version(s): expected v%d.%d.%d, got %s.",
|
||||
newVersion.Major, newVersion.Minor, expectedPatch, newVersion)
|
||||
if err := confirmWithDefault(inv, "Continue?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
// Breaking changes in patch release check.
|
||||
if newVersion.Major == prevVersion.Major && newVersion.Minor == prevVersion.Minor && newVersion.Patch > prevVersion.Patch {
|
||||
infof(w, "Checking for breaking changes in patch release...")
|
||||
|
||||
commitRange := prevVersion.String() + "..HEAD"
|
||||
commits, err := commitLog(commitRange)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading commit log: %w", err)
|
||||
}
|
||||
|
||||
var breakingCommits []commitEntry
|
||||
for _, c := range commits {
|
||||
if breakingCommitRe.MatchString(c.Title) {
|
||||
breakingCommits = append(breakingCommits, c)
|
||||
}
|
||||
}
|
||||
|
||||
// Check PR labels for release/breaking.
|
||||
var breakingPRLabeled []ghPR
|
||||
if ghAvailable {
|
||||
breakingPRLabeled, err = ghListPRsWithLabel(currentBranch, "release/breaking")
|
||||
if err != nil {
|
||||
warnf(w, "Failed to check PR labels: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(breakingCommits) > 0 || len(breakingPRLabeled) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
warnf(w, "BREAKING CHANGES detected in a PATCH release — this violates semver!")
|
||||
fmt.Fprintln(w)
|
||||
if len(breakingCommits) > 0 {
|
||||
fmt.Fprintln(w, " Breaking commits (by conventional commit prefix):")
|
||||
for _, c := range breakingCommits {
|
||||
fmt.Fprintf(w, " - %s %s\n", c.SHA, c.Title)
|
||||
}
|
||||
}
|
||||
if len(breakingPRLabeled) > 0 {
|
||||
fmt.Fprintln(w, " PRs labeled release/breaking:")
|
||||
for _, pr := range breakingPRLabeled {
|
||||
fmt.Fprintf(w, " - #%d %s\n", pr.Number, pr.Title)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if err := confirmWithDefault(inv, "Continue with patch release despite breaking changes?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
} else {
|
||||
successf(w, "No breaking changes detected.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Channel selection ---
|
||||
// This is done before release notes generation because the
|
||||
// notes format differs between mainline and stable channels.
|
||||
channelDefault := cliui.ConfirmNo
|
||||
channelHint := ""
|
||||
if newVersion.Minor == stableMinor {
|
||||
channelDefault = cliui.ConfirmYes
|
||||
channelHint = " (this looks like a stable release)"
|
||||
}
|
||||
|
||||
channel := "mainline"
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Mark this as the latest stable release on GitHub?%s", channelHint),
|
||||
Default: channelDefault,
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err == nil {
|
||||
channel = "stable"
|
||||
} else if !errors.Is(err, cliui.ErrCanceled) {
|
||||
return err
|
||||
}
|
||||
|
||||
if channel == "stable" {
|
||||
infof(w, "Channel: stable (will be marked as GitHub Latest).")
|
||||
} else {
|
||||
infof(w, "Channel: mainline (will be marked as prerelease).")
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// --- Generate release notes ---
|
||||
infof(w, "Generating release notes...")
|
||||
|
||||
commitRange := "HEAD"
|
||||
if prevVersion != nil {
|
||||
commitRange = prevVersion.String() + "..HEAD"
|
||||
}
|
||||
|
||||
commits, err := commitLog(commitRange)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading commit log: %w", err)
|
||||
}
|
||||
|
||||
// Build PR metadata maps (by SHA and PR number) via gh CLI.
|
||||
var prMeta *prMetadataMaps
|
||||
if ghAvailable {
|
||||
prMeta, err = ghBuildPRMetadataMap(commits)
|
||||
if err != nil {
|
||||
warnf(w, "Failed to fetch PR metadata: %v", err)
|
||||
}
|
||||
}
|
||||
if prMeta == nil {
|
||||
prMeta = &prMetadataMaps{
|
||||
bySHA: make(map[string]prMetadata),
|
||||
byNumber: make(map[int]prMetadata),
|
||||
}
|
||||
}
|
||||
|
||||
type section struct {
|
||||
Key string
|
||||
Title string
|
||||
}
|
||||
sections := []section{
|
||||
{"breaking", "BREAKING CHANGES"},
|
||||
{"security", "SECURITY"},
|
||||
{"feat", "Features"},
|
||||
{"fix", "Bug fixes"},
|
||||
{"docs", "Documentation"},
|
||||
{"refactor", "Code refactoring"},
|
||||
{"perf", "Performance improvements"},
|
||||
{"test", "Tests"},
|
||||
{"build", "Builds"},
|
||||
{"ci", "Continuous integration"},
|
||||
{"chore", "Chores"},
|
||||
{"revert", "Reverts"},
|
||||
{"other", "Other changes"},
|
||||
{"experimental", "Experimental changes"},
|
||||
}
|
||||
sectionCommits := make(map[string][]string)
|
||||
|
||||
for _, c := range commits {
|
||||
meta := prMeta.lookupCommit(c.FullSHA, c.PRCount)
|
||||
// Skip dependabot commits.
|
||||
if meta.Author == "dependabot" || meta.Author == "app/dependabot" {
|
||||
continue
|
||||
}
|
||||
cat := categorizeCommit(c.Title, meta.Labels)
|
||||
humanTitle := humanizeTitle(c.Title)
|
||||
// Strip trailing PR ref from humanized title if present,
|
||||
// so we can rebuild it with the SHA appended.
|
||||
humanTitle = prNumRe.ReplaceAllString(humanTitle, "")
|
||||
humanTitle = strings.TrimSpace(humanTitle)
|
||||
// Build entry: - Title (#PR, SHA) (@author)
|
||||
var entry string
|
||||
if c.PRCount > 0 {
|
||||
entry = fmt.Sprintf("- %s (#%d, %s)", humanTitle, c.PRCount, c.SHA)
|
||||
} else {
|
||||
entry = fmt.Sprintf("- %s (%s)", humanTitle, c.SHA)
|
||||
}
|
||||
if meta.Author != "" {
|
||||
entry += fmt.Sprintf(" (@%s)", meta.Author)
|
||||
}
|
||||
sectionCommits[cat] = append(sectionCommits[cat], entry)
|
||||
}
|
||||
|
||||
// Build release notes markdown matching the format from
|
||||
// scripts/release/generate_release_notes.sh.
|
||||
var notes strings.Builder
|
||||
|
||||
// Stable since header or mainline blurb.
|
||||
if channel == "stable" {
|
||||
fmt.Fprintf(¬es, "> ## Stable (since %s)\n\n", time.Now().Format("January 02, 2006"))
|
||||
}
|
||||
fmt.Fprintln(¬es, "## Changelog")
|
||||
if channel == "mainline" {
|
||||
fmt.Fprintln(¬es)
|
||||
fmt.Fprintln(¬es, "> [!NOTE]")
|
||||
fmt.Fprintln(¬es, "> 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/install/releases).")
|
||||
}
|
||||
|
||||
hasContent := false
|
||||
for _, s := range sections {
|
||||
if entries, ok := sectionCommits[s.Key]; ok && len(entries) > 0 {
|
||||
fmt.Fprintf(¬es, "\n### %s\n\n", s.Title)
|
||||
if s.Key == "experimental" {
|
||||
fmt.Fprintln(¬es, "These changes are feature-flagged and can be enabled with the `--experiments` server flag. They may change or be removed in future releases.")
|
||||
fmt.Fprintln(¬es)
|
||||
}
|
||||
for _, e := range entries {
|
||||
fmt.Fprintln(¬es, e)
|
||||
}
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
if !hasContent {
|
||||
prevStr := "the beginning of time"
|
||||
if prevVersion != nil {
|
||||
prevStr = prevVersion.String()
|
||||
}
|
||||
fmt.Fprintf(¬es, "\n_No changes since %s._\n", prevStr)
|
||||
}
|
||||
|
||||
// Compare link.
|
||||
if prevVersion != nil {
|
||||
fmt.Fprintf(¬es, "\nCompare: [`%s...%s`](https://github.com/%s/%s/compare/%s...%s)\n",
|
||||
prevVersion, newVersion, owner, repo, prevVersion, newVersion)
|
||||
}
|
||||
|
||||
// Container image.
|
||||
imageTag := fmt.Sprintf("ghcr.io/coder/coder:%s", strings.TrimPrefix(newVersion.String(), "v"))
|
||||
fmt.Fprintf(¬es, "\n## Container image\n\n- `docker pull %s`\n", imageTag)
|
||||
|
||||
// Install/upgrade links.
|
||||
fmt.Fprintln(¬es, "\n## Install/upgrade")
|
||||
fmt.Fprintln(¬es, "\nRefer to our docs to [install](https://coder.com/docs/install) or [upgrade](https://coder.com/docs/install/upgrade) Coder, or use a release asset below.")
|
||||
|
||||
releaseNotes := notes.String()
|
||||
|
||||
// Write to file.
|
||||
releaseNotesFile := fmt.Sprintf("build/RELEASE-%s.md", newVersion)
|
||||
if err := os.MkdirAll("build", 0o755); err != nil {
|
||||
return xerrors.Errorf("creating build directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(releaseNotesFile, []byte(releaseNotes), 0o600); err != nil {
|
||||
return xerrors.Errorf("writing release notes: %w", err)
|
||||
}
|
||||
|
||||
// --- Preview ---
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), "--- Release Notes Preview ---"))
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprint(w, releaseNotes)
|
||||
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), "--- End Preview ---"))
|
||||
fmt.Fprintln(w)
|
||||
infof(w, "Release notes written to %s", releaseNotesFile)
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// --- Offer to edit ---
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = os.Getenv("GIT_EDITOR")
|
||||
}
|
||||
if editor != "" {
|
||||
if err := confirmWithDefault(inv, fmt.Sprintf("Edit release notes in %s?", editor), cliui.ConfirmNo); err == nil {
|
||||
cmd := exec.Command(editor, releaseNotesFile)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return xerrors.Errorf("editor: %w", err)
|
||||
}
|
||||
updated, err := os.ReadFile(releaseNotesFile)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading edited release notes: %w", err)
|
||||
}
|
||||
// The file will be re-read from disk before the
|
||||
// workflow trigger step.
|
||||
_ = string(updated)
|
||||
infof(w, "Release notes updated.")
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// --- Tag ---
|
||||
ref, err := gitOutput("rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolving HEAD: %w", err)
|
||||
}
|
||||
shortRef := ref[:12]
|
||||
|
||||
if !tagExists {
|
||||
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), "Next step: create an annotated tag."))
|
||||
fmt.Fprintf(w, " Tag: %s\n", newVersion)
|
||||
fmt.Fprintf(w, " Commit: %s\n", shortRef)
|
||||
fmt.Fprintf(w, " Branch: %s\n", currentBranch)
|
||||
fmt.Fprintln(w)
|
||||
if err := confirm(inv, "Create tag?"); err != nil {
|
||||
return xerrors.New("cannot proceed without a tag")
|
||||
}
|
||||
if err := executor.CreateTag(ctx, newVersion.String(), ref, "Release "+newVersion.String(), gpgConfigured); err != nil {
|
||||
return xerrors.Errorf("creating tag: %w", err)
|
||||
}
|
||||
successf(w, "Tag %s created.", newVersion)
|
||||
fmt.Fprintln(w)
|
||||
} else {
|
||||
infof(w, "Tag %s already exists, skipping creation.", newVersion)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// --- Push tag ---
|
||||
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), fmt.Sprintf("Next step: push tag '%s' to origin.", newVersion)))
|
||||
fmt.Fprintf(w, " This will run: git push origin %s\n", newVersion)
|
||||
fmt.Fprintln(w)
|
||||
if err := confirm(inv, "Push tag?"); err != nil {
|
||||
return xerrors.New("cannot trigger release without pushing the tag")
|
||||
}
|
||||
if err := executor.PushTag(ctx, newVersion.String()); err != nil {
|
||||
return xerrors.Errorf("pushing tag: %w", err)
|
||||
}
|
||||
successf(w, "Tag pushed.")
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// --- Trigger release workflow ---
|
||||
// Re-read release notes from disk in case the user edited the
|
||||
// file externally between the editor step and now.
|
||||
freshNotes, err := os.ReadFile(releaseNotesFile)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("re-reading release notes: %w", err)
|
||||
}
|
||||
releaseNotes = string(freshNotes)
|
||||
|
||||
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), "Next step: trigger the 'release.yaml' GitHub Actions workflow."))
|
||||
fmt.Fprintf(w, " Workflow: release.yaml\n")
|
||||
fmt.Fprintf(w, " Repo: %s/%s\n", owner, repo)
|
||||
fmt.Fprintf(w, " Ref: %s\n", newVersion)
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), " Payload fields:"))
|
||||
fmt.Fprintf(w, " release_channel: %s\n", channel)
|
||||
fmt.Fprintf(w, " dry_run: false\n")
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), " release_notes:"))
|
||||
for _, line := range strings.Split(releaseNotes, "\n") {
|
||||
fmt.Fprintf(w, " %s\n", line)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if err := confirm(inv, "Trigger release workflow?"); err != nil {
|
||||
infof(w, "Skipped workflow trigger. You can trigger it manually from GitHub Actions.")
|
||||
fmt.Fprintln(w)
|
||||
successf(w, "Done! 🎉")
|
||||
return nil
|
||||
}
|
||||
if err := executor.TriggerWorkflow(ctx, newVersion.String(), channel, releaseNotes); err != nil {
|
||||
return xerrors.Errorf("triggering workflow: %w", err)
|
||||
}
|
||||
successf(w, "Release workflow triggered!")
|
||||
|
||||
// --- Update release docs ---
|
||||
promptAndUpdateDocs(inv, newVersion, channel, dryRun)
|
||||
|
||||
fmt.Fprintln(w)
|
||||
successf(w, "Done! 🎉")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
// outputPrefix is prepended to every message line. Set to
|
||||
// "[DRYRUN] " when running in dry-run mode.
|
||||
var outputPrefix string
|
||||
|
||||
// warnf prints a yellow warning to stderr.
|
||||
func warnf(w io.Writer, format string, args ...any) {
|
||||
pretty.Fprintf(w, cliui.DefaultStyles.Warn, outputPrefix+format+"\n", args...)
|
||||
}
|
||||
|
||||
// infof prints a cyan info message to stderr.
|
||||
func infof(w io.Writer, format string, args ...any) {
|
||||
pretty.Fprintf(w, cliui.DefaultStyles.Keyword, outputPrefix+format+"\n", args...)
|
||||
}
|
||||
|
||||
// successf prints a green success message to stderr.
|
||||
func successf(w io.Writer, format string, args ...any) {
|
||||
pretty.Fprintf(w, cliui.DefaultStyles.DateTimeStamp, outputPrefix+format+"\n", args...)
|
||||
}
|
||||
|
||||
// confirm asks a yes/no question. Returns nil if the user confirms,
|
||||
// or a cancellation error otherwise.
|
||||
func confirm(inv *serpent.Invocation, msg string) error {
|
||||
_, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: msg,
|
||||
IsConfirm: true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// confirmWithDefault asks a yes/no question with the specified
|
||||
// default ("yes" or "no").
|
||||
func confirmWithDefault(inv *serpent.Invocation, msg, def string) error {
|
||||
_, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: msg,
|
||||
IsConfirm: true,
|
||||
Default: def,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// version holds a parsed semver version.
|
||||
type version struct {
|
||||
Major int
|
||||
Minor int
|
||||
Patch int
|
||||
}
|
||||
|
||||
var semverRe = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)$`)
|
||||
|
||||
func parseVersion(s string) (version, bool) {
|
||||
m := semverRe.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return version{}, false
|
||||
}
|
||||
maj, _ := strconv.Atoi(m[1])
|
||||
mnr, _ := strconv.Atoi(m[2])
|
||||
pat, _ := strconv.Atoi(m[3])
|
||||
return version{Major: maj, Minor: mnr, Patch: pat}, true
|
||||
}
|
||||
|
||||
func (v version) String() string {
|
||||
return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||
}
|
||||
|
||||
func (v version) GreaterThan(b version) bool {
|
||||
if v.Major != b.Major {
|
||||
return v.Major > b.Major
|
||||
}
|
||||
if v.Minor != b.Minor {
|
||||
return v.Minor > b.Minor
|
||||
}
|
||||
return v.Patch > b.Patch
|
||||
}
|
||||
|
||||
func (v version) Equal(b version) bool {
|
||||
return v.Major == b.Major && v.Minor == b.Minor && v.Patch == b.Patch
|
||||
}
|
||||
|
||||
// allSemverTags returns all semver tags sorted descending.
|
||||
func allSemverTags() ([]version, error) {
|
||||
out, err := gitOutput("tag", "--sort=-v:refname")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var tags []version
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if v, ok := parseVersion(strings.TrimSpace(line)); ok {
|
||||
tags = append(tags, v)
|
||||
}
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// mergedSemverTags returns semver tags reachable from HEAD, sorted
|
||||
// descending.
|
||||
func mergedSemverTags() ([]version, error) {
|
||||
out, err := gitOutput("tag", "--merged", "HEAD", "--sort=-v:refname")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var tags []version
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if v, ok := parseVersion(strings.TrimSpace(line)); ok {
|
||||
tags = append(tags, v)
|
||||
}
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
Reference in New Issue
Block a user