chore: add new interactive release package (#22624)

This commit is contained in:
Garrett Delfosse
2026-03-18 12:19:54 -04:00
committed by GitHub
parent a130a7dc97
commit 1f5f6c9ccb
11 changed files with 1879 additions and 443 deletions
+1 -3
View File
@@ -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
View File
@@ -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 "$@"
+228
View File
@@ -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"
}
+519
View File
@@ -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.")
}
}
+91
View File
@@ -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
}
+30
View File
@@ -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()
}
+195
View File
@@ -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
}
+92
View File
@@ -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)
}
}
+584
View File
@@ -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(&notes, "> ## Stable (since %s)\n\n", time.Now().Format("January 02, 2006"))
}
fmt.Fprintln(&notes, "## Changelog")
if channel == "mainline" {
fmt.Fprintln(&notes)
fmt.Fprintln(&notes, "> [!NOTE]")
fmt.Fprintln(&notes, "> 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(&notes, "\n### %s\n\n", s.Title)
if s.Key == "experimental" {
fmt.Fprintln(&notes, "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(&notes)
}
for _, e := range entries {
fmt.Fprintln(&notes, e)
}
hasContent = true
}
}
if !hasContent {
prevStr := "the beginning of time"
if prevVersion != nil {
prevStr = prevVersion.String()
}
fmt.Fprintf(&notes, "\n_No changes since %s._\n", prevStr)
}
// Compare link.
if prevVersion != nil {
fmt.Fprintf(&notes, "\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(&notes, "\n## Container image\n\n- `docker pull %s`\n", imageTag)
// Install/upgrade links.
fmt.Fprintln(&notes, "\n## Install/upgrade")
fmt.Fprintln(&notes, "\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
}
+49
View File
@@ -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
}
+83
View File
@@ -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
}