From 1f5f6c9ccbed7902bcd69ff13847dc96eb4b9a24 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 18 Mar 2026 12:19:54 -0400 Subject: [PATCH] chore: add new interactive release package (#22624) --- .github/workflows/release.yaml | 4 +- scripts/release.sh | 447 +------------------------ scripts/releaser/commit.go | 228 +++++++++++++ scripts/releaser/docs.go | 519 +++++++++++++++++++++++++++++ scripts/releaser/executor.go | 91 +++++ scripts/releaser/git.go | 30 ++ scripts/releaser/github.go | 195 +++++++++++ scripts/releaser/main.go | 92 ++++++ scripts/releaser/release.go | 584 +++++++++++++++++++++++++++++++++ scripts/releaser/ui.go | 49 +++ scripts/releaser/version.go | 83 +++++ 11 files changed, 1879 insertions(+), 443 deletions(-) create mode 100644 scripts/releaser/commit.go create mode 100644 scripts/releaser/docs.go create mode 100644 scripts/releaser/executor.go create mode 100644 scripts/releaser/git.go create mode 100644 scripts/releaser/github.go create mode 100644 scripts/releaser/main.go create mode 100644 scripts/releaser/release.go create mode 100644 scripts/releaser/ui.go create mode 100644 scripts/releaser/version.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 52c348bc08..cd78d91c15 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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: diff --git a/scripts/release.sh b/scripts/release.sh index 6c47ab6f85..0f44a81543 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -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 <] [--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 "$@" diff --git a/scripts/releaser/commit.go b/scripts/releaser/commit.go new file mode 100644 index 0000000000..37bdfcb7b5 --- /dev/null +++ b/scripts/releaser/commit.go @@ -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" +} diff --git a/scripts/releaser/docs.go b/scripts/releaser/docs.go new file mode 100644 index 0000000000..e605d365bf --- /dev/null +++ b/scripts/releaser/docs.go @@ -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 = "" + calendarEndMarker = "" + + 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( + ``, +) + +// 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.") + } +} diff --git a/scripts/releaser/executor.go b/scripts/releaser/executor.go new file mode 100644 index 0000000000..6c92f67aa3 --- /dev/null +++ b/scripts/releaser/executor.go @@ -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 +} diff --git a/scripts/releaser/git.go b/scripts/releaser/git.go new file mode 100644 index 0000000000..3974e21582 --- /dev/null +++ b/scripts/releaser/git.go @@ -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() +} diff --git a/scripts/releaser/github.go b/scripts/releaser/github.go new file mode 100644 index 0000000000..2438ec3b24 --- /dev/null +++ b/scripts/releaser/github.go @@ -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 +} diff --git a/scripts/releaser/main.go b/scripts/releaser/main.go new file mode 100644 index 0000000000..5ea47b64d0 --- /dev/null +++ b/scripts/releaser/main.go @@ -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) + } +} diff --git a/scripts/releaser/release.go b/scripts/releaser/release.go new file mode 100644 index 0000000000..9b2b98a6a5 --- /dev/null +++ b/scripts/releaser/release.go @@ -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 +} diff --git a/scripts/releaser/ui.go b/scripts/releaser/ui.go new file mode 100644 index 0000000000..b178e60c0d --- /dev/null +++ b/scripts/releaser/ui.go @@ -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 +} diff --git a/scripts/releaser/version.go b/scripts/releaser/version.go new file mode 100644 index 0000000000..cb1a7f04aa --- /dev/null +++ b/scripts/releaser/version.go @@ -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 +}