ci: rewrite release workflow to be fully GitHub Actions-driven (#25162)

Replace the local interactive release CLI and legacy shell scripts with
a non-interactive Go tool (`scripts/release-action/`) and a rewritten
`release.yaml` workflow. Release managers trigger releases from the
GitHub Actions UI by selecting a branch, picking a release type (`rc`,
`release`, or `create-release-branch`), and optionally providing a
commit SHA.

The Go tool has four subcommands: `calculate-version` (computes next
version from git state), `generate-notes` (release notes from commit log
and PR metadata), `publish` (creates GitHub release with checksums), and
the workflow handles tag creation, branch creation, building, and
downstream publishing.

`scripts/version.sh` fallback now uses `git describe` (nearest ancestor
tag) instead of global latest so dev builds on release branches show the
correct version series.
This commit is contained in:
Garrett Delfosse
2026-06-04 14:38:48 -04:00
committed by GitHub
parent d5b0e93c6c
commit b95697a370
14 changed files with 2485 additions and 178 deletions
+442
View File
@@ -0,0 +1,442 @@
package main
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"golang.org/x/xerrors"
)
// calculateResult is implemented by both ReleaseRequest and
// CreateBranchRequest so calculateNextVersion can return either.
type calculateResult interface {
String() string
}
// ReleaseRequest is the JSON output of calculate-version for rc and
// release types.
type ReleaseRequest struct {
Version string `json:"version"`
PreviousVersion string `json:"previous_version"`
Stable bool `json:"stable"`
TargetRef string `json:"target_ref"`
}
// String returns the result as indented JSON.
func (r ReleaseRequest) String() string {
b, _ := json.MarshalIndent(r, "", " ")
return string(b)
}
// CreateBranchRequest is the JSON output of calculate-version for the
// create-release-branch type.
type CreateBranchRequest struct {
ReleaseRequest
BranchName string `json:"create_branch"`
}
// String returns the result as indented JSON.
func (r CreateBranchRequest) String() string {
b, _ := json.MarshalIndent(r, "", " ")
return string(b)
}
var branchRe = regexp.MustCompile(`^release/(\d+)\.(\d+)$`)
// calculateNextVersion dispatches to the appropriate calculation.
//
// ref is the branch name from the "Use workflow from" dropdown
// (github.ref_name). commitSHA is an optional override; when empty
// the tool defaults to HEAD of the ref.
func calculateNextVersion(releaseType, ref, commitSHA string) (calculateResult, error) {
// Ensure we have up-to-date remote state.
if _, err := gitOutput("fetch", "--tags", "--force", "origin"); err != nil {
return nil, xerrors.Errorf("git fetch: %w", err)
}
isReleaseBranch := branchRe.MatchString(ref)
isMain := ref == "main"
switch releaseType {
case "rc":
if !isMain && !isReleaseBranch {
return nil, xerrors.Errorf("rc must be run from main or a release/X.Y branch, got %q", ref)
}
if isMain {
return calculateRCFromMainReleaseRequest(ref, commitSHA)
}
return calculateRCFromBranchReleaseRequest(ref, commitSHA)
case "release":
if !isReleaseBranch {
return nil, xerrors.Errorf("release must be run from a release/X.Y branch, got %q", ref)
}
return createRegularReleaseRequest(ref)
case "create-release-branch":
if !isMain {
return nil, xerrors.Errorf("create-release-branch must be run from main, got %q", ref)
}
return calculateCreateBranchRequest(ref, commitSHA)
default:
return nil, xerrors.Errorf("unknown release type %q (expected rc, release, or create-release-branch)", releaseType)
}
}
// resolveCommit returns the commit SHA to tag. If commitSHA is
// provided it is validated and returned; otherwise HEAD of the
// ref is used.
func resolveCommit(ref, commitSHA string) (string, error) {
if commitSHA != "" {
if !isHexSHA(commitSHA) {
return "", xerrors.Errorf("invalid commit SHA %q: must be a hex string", commitSHA)
}
return commitSHA, nil
}
sha, err := gitOutput("rev-parse", fmt.Sprintf("origin/%s", ref))
if err != nil {
return "", xerrors.Errorf("resolve HEAD of %s: %w", ref, err)
}
return sha, nil
}
// calculateRCFromMainReleaseRequest tags an RC from a commit on main.
func calculateRCFromMainReleaseRequest(ref, commitSHA string) (ReleaseRequest, error) {
targetRef, err := resolveCommit(ref, commitSHA)
if err != nil {
return ReleaseRequest{}, err
}
// Verify commit is an ancestor of origin/main.
if err := gitRun("merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil {
return ReleaseRequest{}, xerrors.Errorf("commit %s is not an ancestor of origin/main", targetRef)
}
allTags, err := listSemverTags()
if err != nil {
return ReleaseRequest{}, err
}
// Find latest RC globally to determine series.
latestRC := findLatestRC(allTags)
latestRelease := findLatestNonRC(allTags)
var major, minor, rcNum int
switch {
case latestRC.original != "":
major = latestRC.major
minor = latestRC.minor
rcNum = latestRC.rc + 1
// If there is a final release for this series, bump minor.
if latestRelease.original != "" &&
latestRelease.major == major &&
latestRelease.minor == minor {
minor++
rcNum = 0
}
case latestRelease.original != "":
major = latestRelease.major
minor = latestRelease.minor + 1
rcNum = 0
default:
return ReleaseRequest{}, xerrors.New("no existing tags found to base RC on")
}
newVer := version{major: major, minor: minor, patch: 0, rc: rcNum}
prevTag := findPreviousTag(allTags, newVer)
return ReleaseRequest{
Version: newVer.String(),
PreviousVersion: prevTag,
TargetRef: targetRef,
}, nil
}
// calculateRCFromBranchReleaseRequest tags an RC from the tip of a release branch.
func calculateRCFromBranchReleaseRequest(ref, commitSHA string) (ReleaseRequest, error) {
m := branchRe.FindStringSubmatch(ref)
if m == nil {
return ReleaseRequest{}, xerrors.Errorf("ref %q does not match release/X.Y", ref)
}
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
targetRef, err := resolveCommit(ref, commitSHA)
if err != nil {
return ReleaseRequest{}, err
}
// Fail if there are open PRs targeting this release branch.
if err := checkOpenPRs(ref); err != nil {
return ReleaseRequest{}, err
}
allTags, err := listSemverTags()
if err != nil {
return ReleaseRequest{}, err
}
// Find tags for this series.
seriesTags := filterTagsForSeries(allTags, major, minor)
// If the series already has a final release, this is an error;
// you should be cutting a new minor, not more RCs.
for _, t := range seriesTags {
if t.rc < 0 {
return ReleaseRequest{}, xerrors.Errorf(
"release %s already exists for this series; cut a new minor instead of another RC",
t.original,
)
}
}
rcNum := 0
for _, t := range seriesTags {
if t.rc >= rcNum {
rcNum = t.rc + 1
}
}
newVer := version{major: major, minor: minor, patch: 0, rc: rcNum}
prevTag := findPreviousTag(allTags, newVer)
return ReleaseRequest{
Version: newVer.String(),
PreviousVersion: prevTag,
TargetRef: targetRef,
}, nil
}
// createRegularReleaseRequest calculates the next release (non-RC) version from
// a release branch. Uses HEAD of the branch.
func createRegularReleaseRequest(ref string) (ReleaseRequest, error) {
m := branchRe.FindStringSubmatch(ref)
if m == nil {
return ReleaseRequest{}, xerrors.Errorf("ref %q does not match release/X.Y", ref)
}
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
// Resolve branch HEAD.
headSHA, err := gitOutput("rev-parse", fmt.Sprintf("origin/%s", ref))
if err != nil {
return ReleaseRequest{}, xerrors.Errorf("resolve branch %s: %w", ref, err)
}
// Fail if there are open PRs targeting this release branch.
if err := checkOpenPRs(ref); err != nil {
return ReleaseRequest{}, err
}
allTags, err := listSemverTags()
if err != nil {
return ReleaseRequest{}, err
}
// Find tags for this series.
seriesTags := filterTagsForSeries(allTags, major, minor)
// Determine next patch version.
nextPatch := 0
for _, t := range seriesTags {
if t.rc < 0 && t.patch >= nextPatch {
nextPatch = t.patch + 1
}
}
newVer := version{major: major, minor: minor, patch: nextPatch, rc: -1}
prevTag := findPreviousTag(allTags, newVer)
return ReleaseRequest{
Version: newVer.String(),
PreviousVersion: prevTag,
Stable: isStable(major, minor, allTags),
TargetRef: headSHA,
}, nil
}
// calculateCreateBranchRequest creates a release branch and tags the next
// RC in one atomic step. Must be run from main.
func calculateCreateBranchRequest(ref, commitSHA string) (CreateBranchRequest, error) {
targetRef, err := resolveCommit(ref, commitSHA)
if err != nil {
return CreateBranchRequest{}, err
}
// Verify commit is an ancestor of origin/main.
if err := gitRun("merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil {
return CreateBranchRequest{}, xerrors.Errorf("commit %s is not an ancestor of origin/main", targetRef)
}
allTags, err := listSemverTags()
if err != nil {
return CreateBranchRequest{}, err
}
// Find latest non-RC release.
latest := findLatestNonRC(allTags)
if latest.original == "" {
return CreateBranchRequest{}, xerrors.New("no existing releases found")
}
nextMajor := latest.major
nextMinor := latest.minor + 1
branchName := fmt.Sprintf("release/%d.%d", nextMajor, nextMinor)
// Check that the branch doesn't already exist.
if _, err := gitOutput("rev-parse", "--verify", fmt.Sprintf("origin/%s", branchName)); err == nil {
return CreateBranchRequest{}, xerrors.Errorf("branch %s already exists", branchName)
}
// Find existing RCs for this series to continue the sequence.
rcNum := 0
seriesTags := filterTagsForSeries(allTags, nextMajor, nextMinor)
for _, t := range seriesTags {
if t.rc >= rcNum {
rcNum = t.rc + 1
}
}
newVer := version{major: nextMajor, minor: nextMinor, patch: 0, rc: rcNum}
prevTag := findPreviousTag(allTags, newVer)
return CreateBranchRequest{
ReleaseRequest: ReleaseRequest{
Version: newVer.String(),
PreviousVersion: prevTag,
TargetRef: targetRef,
},
BranchName: branchName,
}, nil
}
// isStable returns true if this minor series is exactly one behind
// the latest released minor (i.e. it is the "stable" channel).
func isStable(major, minor int, allTags []version) bool {
latest := findLatestNonRC(allTags)
return latest.original != "" && latest.major == major && latest.minor == minor+1
}
// isHexSHA validates that s looks like a hex commit SHA.
func isHexSHA(s string) bool {
if len(s) < 7 {
return false
}
for _, c := range s {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
// findLatestRC returns the highest RC version from the tag list.
func findLatestRC(tags []version) version {
var best version
for _, t := range tags {
if t.rc < 0 {
continue
}
if best.original == "" || versionIsLess(best, t) {
best = t
}
}
return best
}
// findLatestNonRC returns the highest non-RC version from the tag list.
func findLatestNonRC(tags []version) version {
var best version
for _, t := range tags {
if t.rc >= 0 {
continue
}
if best.original == "" || versionIsLess(best, t) {
best = t
}
}
return best
}
// filterTagsForSeries returns tags matching the given major.minor.
func filterTagsForSeries(tags []version, major, minor int) []version {
var out []version
for _, t := range tags {
if t.major == major && t.minor == minor {
out = append(out, t)
}
}
return out
}
// findPreviousTag returns the version string of the best previous
// tag for building a changelog range. It picks the highest tag that
// is strictly less than newVer.
func findPreviousTag(tags []version, newVer version) string {
var best version
for _, t := range tags {
if !versionIsLess(t, newVer) {
continue
}
if best.original == "" || versionIsLess(best, t) {
best = t
}
}
return best.original
}
// versionIsLess returns true if a < b using semver ordering.
func versionIsLess(a, b version) bool {
if a.major != b.major {
return a.major < b.major
}
if a.minor != b.minor {
return a.minor < b.minor
}
if a.patch != b.patch {
return a.patch < b.patch
}
// Non-RC (rc == -1) is greater than any RC.
if a.rc < 0 && b.rc < 0 {
return false
}
if a.rc < 0 {
return false
}
if b.rc < 0 {
return true
}
return a.rc < b.rc
}
// listSemverTags returns all semver tags from the repo.
func listSemverTags() ([]version, error) {
out, err := gitOutput("tag", "--list", "v*")
if err != nil {
return nil, xerrors.Errorf("list tags: %w", err)
}
if out == "" {
return nil, nil
}
var tags []version
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
v, err := parseVersion(line)
if err != nil {
continue // skip non-semver tags
}
tags = append(tags, v)
}
return tags, nil
}
+427
View File
@@ -0,0 +1,427 @@
package main
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_versionIsLess(t *testing.T) {
t.Parallel()
tests := []struct {
name string
a, b version
want bool
}{
{
name: "major_less",
a: version{major: 1, minor: 0, patch: 0, rc: -1, original: "v1.0.0"},
b: version{major: 2, minor: 0, patch: 0, rc: -1, original: "v2.0.0"},
want: true,
},
{
name: "major_greater",
a: version{major: 3, minor: 0, patch: 0, rc: -1, original: "v3.0.0"},
b: version{major: 2, minor: 0, patch: 0, rc: -1, original: "v2.0.0"},
want: false,
},
{
name: "minor_less",
a: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"},
b: version{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"},
want: true,
},
{
name: "minor_greater",
a: version{major: 2, minor: 5, patch: 0, rc: -1, original: "v2.5.0"},
b: version{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"},
want: false,
},
{
name: "patch_less",
a: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"},
b: version{major: 2, minor: 1, patch: 3, rc: -1, original: "v2.1.3"},
want: true,
},
{
name: "patch_greater",
a: version{major: 2, minor: 1, patch: 5, rc: -1, original: "v2.1.5"},
b: version{major: 2, minor: 1, patch: 3, rc: -1, original: "v2.1.3"},
want: false,
},
{
name: "rc_less_than_non_rc",
a: version{major: 2, minor: 1, patch: 0, rc: 5, original: "v2.1.0-rc.5"},
b: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"},
want: true,
},
{
name: "non_rc_not_less_than_rc",
a: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"},
b: version{major: 2, minor: 1, patch: 0, rc: 5, original: "v2.1.0-rc.5"},
want: false,
},
{
name: "equal_non_rc",
a: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"},
b: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"},
want: false,
},
{
name: "equal_rc",
a: version{major: 2, minor: 1, patch: 0, rc: 3, original: "v2.1.0-rc.3"},
b: version{major: 2, minor: 1, patch: 0, rc: 3, original: "v2.1.0-rc.3"},
want: false,
},
{
name: "rc_ordering",
a: version{major: 2, minor: 1, patch: 0, rc: 1, original: "v2.1.0-rc.1"},
b: version{major: 2, minor: 1, patch: 0, rc: 3, original: "v2.1.0-rc.3"},
want: true,
},
{
name: "rc_ordering_reverse",
a: version{major: 2, minor: 1, patch: 0, rc: 3, original: "v2.1.0-rc.3"},
b: version{major: 2, minor: 1, patch: 0, rc: 1, original: "v2.1.0-rc.1"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, versionIsLess(tt.a, tt.b))
})
}
}
func Test_findLatestRC(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tags []version
want version
}{
{
name: "empty_list",
tags: nil,
want: version{},
},
{
name: "no_rcs",
tags: []version{
{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"},
{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"},
},
want: version{},
},
{
name: "multiple_rcs_across_series",
tags: []version{
{major: 2, minor: 1, patch: 0, rc: 0, original: "v2.1.0-rc.0"},
{major: 2, minor: 2, patch: 0, rc: 3, original: "v2.2.0-rc.3"},
{major: 2, minor: 2, patch: 0, rc: 1, original: "v2.2.0-rc.1"},
{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"},
},
want: version{major: 2, minor: 2, patch: 0, rc: 3, original: "v2.2.0-rc.3"},
},
{
name: "single_rc",
tags: []version{
{major: 1, minor: 0, patch: 0, rc: 0, original: "v1.0.0-rc.0"},
},
want: version{major: 1, minor: 0, patch: 0, rc: 0, original: "v1.0.0-rc.0"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := findLatestRC(tt.tags)
require.Equal(t, tt.want, got)
})
}
}
func Test_findLatestNonRC(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tags []version
want version
}{
{
name: "empty_list",
tags: nil,
want: version{},
},
{
name: "no_non_rcs",
tags: []version{
{major: 2, minor: 1, patch: 0, rc: 0, original: "v2.1.0-rc.0"},
{major: 2, minor: 2, patch: 0, rc: 3, original: "v2.2.0-rc.3"},
},
want: version{},
},
{
name: "multiple_releases",
tags: []version{
{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"},
{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"},
{major: 2, minor: 2, patch: 0, rc: 3, original: "v2.2.0-rc.3"},
{major: 2, minor: 1, patch: 1, rc: -1, original: "v2.1.1"},
},
want: version{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"},
},
{
name: "single_release",
tags: []version{
{major: 1, minor: 0, patch: 0, rc: -1, original: "v1.0.0"},
},
want: version{major: 1, minor: 0, patch: 0, rc: -1, original: "v1.0.0"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := findLatestNonRC(tt.tags)
require.Equal(t, tt.want, got)
})
}
}
func Test_findPreviousTag(t *testing.T) {
t.Parallel()
tags := []version{
{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"},
{major: 2, minor: 2, patch: 0, rc: 0, original: "v2.2.0-rc.0"},
{major: 2, minor: 2, patch: 0, rc: 1, original: "v2.2.0-rc.1"},
{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"},
}
tests := []struct {
name string
newVer version
want string
}{
{
name: "normal_case",
newVer: version{major: 2, minor: 2, patch: 0, rc: 2, original: "v2.2.0-rc.2"},
want: "v2.2.0-rc.1",
},
{
name: "no_previous",
newVer: version{major: 1, minor: 0, patch: 0, rc: 0, original: "v1.0.0-rc.0"},
want: "",
},
{
name: "exact_match_excluded",
newVer: version{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"},
want: "v2.2.0-rc.1",
},
{
name: "picks_highest_lesser",
newVer: version{major: 3, minor: 0, patch: 0, rc: -1, original: "v3.0.0"},
want: "v2.2.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := findPreviousTag(tags, tt.newVer)
require.Equal(t, tt.want, got)
})
}
}
func Test_filterTagsForSeries(t *testing.T) {
t.Parallel()
tags := []version{
{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"},
{major: 2, minor: 2, patch: 0, rc: 0, original: "v2.2.0-rc.0"},
{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"},
{major: 3, minor: 2, patch: 0, rc: -1, original: "v3.2.0"},
}
tests := []struct {
name string
major int
minor int
wantCount int
wantFirst string
wantSecond string
}{
{
name: "matching_tags",
major: 2,
minor: 2,
wantCount: 2,
wantFirst: "v2.2.0-rc.0",
wantSecond: "v2.2.0",
},
{
name: "no_matching_tags",
major: 4,
minor: 0,
wantCount: 0,
},
{
name: "single_match",
major: 2,
minor: 1,
wantCount: 1,
wantFirst: "v2.1.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := filterTagsForSeries(tags, tt.major, tt.minor)
require.Len(t, got, tt.wantCount)
if tt.wantCount > 0 {
require.Equal(t, tt.wantFirst, got[0].original)
}
if tt.wantCount > 1 {
require.Equal(t, tt.wantSecond, got[1].original)
}
})
}
}
func Test_isStable(t *testing.T) {
t.Parallel()
tests := []struct {
name string
major int
minor int
tags []version
want bool
}{
{
name: "latest_is_minor_plus_one_stable",
major: 2,
minor: 20,
tags: []version{
{major: 2, minor: 21, patch: 0, rc: -1, original: "v2.21.0"},
},
want: true,
},
{
name: "latest_is_same_minor_not_stable",
major: 2,
minor: 21,
tags: []version{
{major: 2, minor: 21, patch: 0, rc: -1, original: "v2.21.0"},
},
want: false,
},
{
name: "latest_is_minor_plus_two_not_stable",
major: 2,
minor: 19,
tags: []version{
{major: 2, minor: 21, patch: 0, rc: -1, original: "v2.21.0"},
},
want: false,
},
{
name: "no_tags",
major: 2,
minor: 20,
tags: nil,
want: false,
},
{
name: "only_rcs_no_releases",
major: 2,
minor: 20,
tags: []version{
{major: 2, minor: 21, patch: 0, rc: 0, original: "v2.21.0-rc.0"},
},
want: false,
},
{
name: "different_major_not_stable",
major: 2,
minor: 20,
tags: []version{
{major: 3, minor: 21, patch: 0, rc: -1, original: "v3.21.0"},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, isStable(tt.major, tt.minor, tt.tags))
})
}
}
func Test_isHexSHA(t *testing.T) {
t.Parallel()
tests := []struct {
name string
s string
want bool
}{
{
name: "valid_short_sha",
s: "abc1234",
want: true,
},
{
name: "valid_long_sha",
s: "abc1234def5678901234567890abcdef12345678",
want: true,
},
{
name: "valid_uppercase",
s: "ABCDEF1234567",
want: true,
},
{
name: "too_short",
s: "abc12",
want: false,
},
{
name: "exactly_six_chars",
s: "abc123",
want: false,
},
{
name: "non_hex_chars",
s: "xyz1234",
want: false,
},
{
name: "empty",
s: "",
want: false,
},
{
name: "seven_chars_valid",
s: "abcdef1",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, isHexSHA(tt.s))
})
}
}
+221
View File
@@ -0,0 +1,221 @@
package main
import (
"regexp"
"sort"
"strconv"
"strings"
)
// commitEntry represents a single non-merge commit.
type commitEntry struct {
SHA string
FullSHA string
Title string
Timestamp int64
}
// cherryPickPRRe matches cherry-pick bot titles like
// "chore: foo bar (cherry-pick #42) (#43)".
var cherryPickPRRe = regexp.MustCompile(`\(cherry-pick #(\d+)\)\s*\(#\d+\)$`)
// 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"},
}
// 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] + ")"
}
entries = append(entries, commitEntry{
SHA: shortSHA,
FullSHA: fullSHA,
Title: title,
Timestamp: ts,
})
}
// 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]
}
// 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"
}
+352
View File
@@ -0,0 +1,352 @@
package main
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_humanizeTitle(t *testing.T) {
t.Parallel()
tests := []struct {
name string
title string
want string
}{
{
name: "feat_site_scope",
title: "feat(site): add bar",
want: "Dashboard: Add bar",
},
{
name: "fix_coderd_scope",
title: "fix(coderd): thing",
want: "Server: Thing",
},
{
name: "fix_agent_scope",
title: "fix(agent): reconnect",
want: "Agent: Reconnect",
},
{
name: "feat_cli_scope",
title: "feat(cli): new flag",
want: "CLI: New flag",
},
{
name: "fix_tailnet_scope",
title: "fix(tailnet): routing issue",
want: "Networking: Routing issue",
},
{
name: "feat_codersdk_scope",
title: "feat(codersdk): new method",
want: "SDK: New method",
},
{
name: "feat_docs_scope",
title: "feat(docs): add guide",
want: "Documentation: Add guide",
},
{
name: "fix_enterprise_coderd_scope",
title: "fix(enterprise/coderd): auth bug",
want: "Server: Auth bug",
},
{
name: "no_scope",
title: "feat: thing",
want: "Thing",
},
{
name: "non_conventional_title",
title: "Update README",
want: "Update README",
},
{
name: "breaking_with_bang_unchanged",
title: "feat!: thing",
want: "feat!: thing",
},
{
name: "breaking_with_scope_and_bang_unchanged",
title: "feat(site)!: remove old api",
want: "feat(site)!: remove old api",
},
{
name: "unknown_scope_returns_original",
title: "fix(unknownscope): something",
want: "fix(unknownscope): something",
},
{
name: "agent_agentssh_more_specific",
title: "fix(agent/agentssh): session bug",
want: "Agent SSH: Session bug",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, humanizeTitle(tt.title))
})
}
}
func Test_categorizeCommit(t *testing.T) {
t.Parallel()
tests := []struct {
name string
title string
labels []string
want string
}{
{
name: "breaking_via_bang_in_title",
title: "feat!: remove old api",
want: "breaking",
},
{
name: "breaking_via_scoped_bang",
title: "fix(coderd)!: breaking change",
want: "breaking",
},
{
name: "breaking_via_label",
title: "feat(site): add thing",
labels: []string{"release/breaking"},
want: "breaking",
},
{
name: "security_label",
title: "fix(coderd): patch vuln",
labels: []string{"security"},
want: "security",
},
{
name: "experimental_label",
title: "feat(site): new feature",
labels: []string{"release/experimental"},
want: "experimental",
},
{
name: "feat_prefix",
title: "feat(site): add bar",
want: "feat",
},
{
name: "fix_prefix",
title: "fix(coderd): thing",
want: "fix",
},
{
name: "chore_prefix",
title: "chore: update deps",
want: "chore",
},
{
name: "docs_prefix",
title: "docs: update readme",
want: "docs",
},
{
name: "refactor_prefix",
title: "refactor(coderd): simplify",
want: "refactor",
},
{
name: "unknown_prefix",
title: "yolo: do something",
want: "other",
},
{
name: "no_prefix",
title: "Update README",
want: "other",
},
{
name: "breaking_label_takes_priority_over_feat",
title: "feat(coderd): new api",
labels: []string{"release/breaking"},
want: "breaking",
},
{
name: "security_takes_priority_over_experimental",
title: "fix(coderd): vuln",
labels: []string{"security", "release/experimental"},
want: "security",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, categorizeCommit(tt.title, tt.labels))
})
}
}
func Test_commitSortPrefix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
title string
want string
}{
{
name: "space_delimiter",
title: "feat something",
want: "feat",
},
{
name: "colon_delimiter",
title: "feat: something",
want: "feat",
},
{
name: "paren_delimiter",
title: "feat(site): something",
want: "feat",
},
{
name: "no_delimiter",
title: "single",
want: "single",
},
{
name: "empty_string",
title: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, commitSortPrefix(tt.title))
})
}
}
func Test_parsePRNumbers(t *testing.T) {
t.Parallel()
tests := []struct {
name string
title string
want []int
}{
{
name: "single_pr",
title: "feat(site): add bar (#123)",
want: []int{123},
},
{
name: "multiple_prs",
title: "fix (#42) then (#43)",
want: []int{42, 43},
},
{
name: "no_pr_numbers",
title: "feat(site): add bar",
want: nil,
},
{
name: "cherry_pick_only_matches_parens",
title: "chore: foo (cherry-pick #42) (#43)",
want: []int{43},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := parsePRNumbers(tt.title)
require.Equal(t, tt.want, got)
})
}
}
func Test_stripPRRef(t *testing.T) {
t.Parallel()
tests := []struct {
name string
title string
want string
}{
{
name: "removes_trailing_pr_ref",
title: "Dashboard: Add bar (#123)",
want: "Dashboard: Add bar",
},
{
name: "no_pr_ref",
title: "Dashboard: Add bar",
want: "Dashboard: Add bar",
},
{
name: "multiple_pr_refs_strips_last",
title: "Foo (#42) (#43)",
want: "Foo (#42)",
},
{
name: "pr_ref_with_whitespace",
title: "Title (#999)",
want: "Title",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, stripPRRef(tt.title))
})
}
}
func Test_isDependabot(t *testing.T) {
t.Parallel()
tests := []struct {
name string
title string
want bool
}{
{
name: "contains_dependabot",
title: "chore: bump dependabot/fetch-metadata (#456)",
want: true,
},
{
name: "chore_deps_prefix",
title: "chore(deps): bump golang.org/x/net",
want: true,
},
{
name: "normal_title",
title: "feat(site): add bar (#123)",
want: false,
},
{
name: "case_insensitive_dependabot",
title: "Bump Dependabot thing",
want: true,
},
{
name: "chore_deps_uppercase",
title: "Chore(Deps): update things",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, isDependabot(tt.title))
})
}
}
+29
View File
@@ -0,0 +1,29 @@
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, discarding stdout/stderr. Use this
// for commands where only the exit code matters (e.g. merge-base
// --is-ancestor).
func gitRun(args ...string) error {
cmd := exec.Command("git", args...)
return cmd.Run()
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"golang.org/x/xerrors"
)
// 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 {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// pullRequest holds metadata about a GitHub pull request.
type pullRequest struct {
Number int
Title string
Labels []string
Author string
URL string
}
// pullRequestMap holds PR metadata indexed by PR number.
type pullRequestMap map[int]pullRequest
// ghBuildPullRequestMap builds a map of PR number to metadata by
// querying the GitHub API via the gh CLI for the given PR numbers.
func ghBuildPullRequestMap(prNumbers []int) pullRequestMap {
m := make(pullRequestMap)
for _, prNum := range prNumbers {
out, err := ghOutput("pr", "view", fmt.Sprintf("%d", prNum),
"--repo", fmt.Sprintf("%s/%s", owner, repo),
"--json", "number,labels,author")
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "warning: failed to fetch PR #%d metadata: %v\n", prNum, err)
continue
}
var result struct {
Number int `json:"number"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
Author struct {
Login string `json:"login"`
} `json:"author"`
}
if err := json.Unmarshal([]byte(out), &result); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "warning: failed to parse PR #%d metadata: %v\n", prNum, err)
continue
}
var labels []string
for _, l := range result.Labels {
labels = append(labels, l.Name)
}
m[result.Number] = pullRequest{
Number: result.Number,
Labels: labels,
Author: result.Author.Login,
}
}
return m
}
// checkOpenPRs verifies that no pull requests are open against the
// given branch. If any are found, it returns an error listing them
// with instructions to merge or close before releasing.
func checkOpenPRs(branch string) error {
out, err := ghOutput("pr", "list",
"--repo", fmt.Sprintf("%s/%s", owner, repo),
"--base", branch,
"--state", "open",
"--json", "number,title,author,url",
"--limit", "100")
if err != nil {
return xerrors.Errorf("failed to list open PRs for branch %s: %w", branch, err)
}
var rawPRs []struct {
Number int `json:"number"`
Title string `json:"title"`
Author struct {
Login string `json:"login"`
} `json:"author"`
URL string `json:"url"`
}
if err := json.Unmarshal([]byte(out), &rawPRs); err != nil {
return xerrors.Errorf("failed to parse open PRs response: %w", err)
}
if len(rawPRs) == 0 {
return nil
}
var b strings.Builder
_, _ = fmt.Fprintf(&b, "found %d open pull request(s) targeting %s that must be merged or closed before releasing:\n\n", len(rawPRs), branch)
for _, pr := range rawPRs {
_, _ = fmt.Fprintf(&b, " - #%d: %s (by @%s)\n %s\n", pr.Number, pr.Title, pr.Author.Login, pr.URL)
}
_, _ = fmt.Fprintf(&b, "\nMerge or close these pull requests, then re-run the release workflow.")
return xerrors.New(b.String())
}
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"errors"
"fmt"
"os"
"golang.org/x/xerrors"
"github.com/coder/serpent"
)
const (
owner = "coder"
repo = "coder"
)
func main() {
var (
releaseType string
ref string
commitSHA string
versionStr string
prevVersionStr string
notesFile string
stable bool
)
cmd := &serpent.Command{
Use: "release-action <subcommand>",
Short: "Non-interactive, CI-oriented release tool for coder/coder.",
Children: []*serpent.Command{
{
Use: "calculate-version",
Short: "Calculate the next release version from git state.",
Options: serpent.OptionSet{
{
Name: "type",
Flag: "type",
Description: "Release type: rc, release, or create-release-branch.",
Value: serpent.StringOf(&releaseType),
Required: true,
},
{
Name: "ref",
Flag: "ref",
Description: "Git ref (branch name) the workflow is running on.",
Value: serpent.StringOf(&ref),
Required: true,
},
{
Name: "commit",
Flag: "commit",
Description: "Commit SHA to tag (defaults to HEAD of --ref if empty).",
Value: serpent.StringOf(&commitSHA),
},
},
Handler: func(inv *serpent.Invocation) error {
result, err := calculateNextVersion(releaseType, ref, commitSHA)
if err != nil {
return err
}
_, _ = fmt.Fprintln(inv.Stdout, result.String())
return nil
},
},
{
Use: "generate-notes",
Short: "Generate release notes from commit log and PR metadata.",
Options: serpent.OptionSet{
{
Name: "version",
Flag: "version",
Description: "New release version (e.g. v2.21.0).",
Value: serpent.StringOf(&versionStr),
Required: true,
},
{
Name: "previous-version",
Flag: "previous-version",
Description: "Previous release version (e.g. v2.20.0).",
Value: serpent.StringOf(&prevVersionStr),
Required: true,
},
},
Handler: func(inv *serpent.Invocation) error {
newVer, err := parseVersion(versionStr)
if err != nil {
return xerrors.Errorf("parse --version: %w", err)
}
prevVer, err := parseVersion(prevVersionStr)
if err != nil {
return xerrors.Errorf("parse --previous-version: %w", err)
}
notes, err := generateReleaseNotes(newVer, prevVer)
if err != nil {
return err
}
_, _ = fmt.Fprint(inv.Stdout, notes)
return nil
},
},
{
Use: "publish",
Short: "Publish a GitHub release with assets and checksums.",
Options: serpent.OptionSet{
{
Name: "version",
Flag: "version",
Description: "Release version tag (e.g. v2.21.0).",
Value: serpent.StringOf(&versionStr),
Required: true,
},
{
Name: "stable",
Flag: "stable",
Description: "Mark this release as the latest stable release.",
Value: serpent.BoolOf(&stable),
},
{
Name: "release-notes-file",
Flag: "release-notes-file",
Description: "Path to release notes markdown file.",
Value: serpent.StringOf(&notesFile),
Required: true,
},
},
Handler: func(inv *serpent.Invocation) error {
assets := inv.Args
if len(assets) == 0 {
return xerrors.New("no asset files provided as arguments")
}
return publishRelease(versionStr, stable, notesFile, assets)
},
},
},
}
err := cmd.Invoke().WithOS().Run()
if err != nil {
// Unwrap serpent's "running command ..." wrapper to keep output clean.
var runErr *serpent.RunCommandError
if errors.As(err, &runErr) {
err = runErr.Err
}
_, _ = fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
}
}
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"fmt"
"regexp"
"strconv"
"strings"
"golang.org/x/xerrors"
)
// generateReleaseNotes produces markdown release notes for the given
// version range by examining the commit log and PR metadata.
func generateReleaseNotes(newVersion, previousVersion version) (string, error) {
// Build commit range. If the new tag doesn't exist locally yet,
// fall back to ..HEAD.
newTag := newVersion.String()
commitRange := fmt.Sprintf("%s...%s", previousVersion.String(), newTag)
if err := gitRun("rev-parse", "--verify", newTag); err != nil {
commitRange = fmt.Sprintf("%s..HEAD", previousVersion.String())
}
commits, err := commitLog(commitRange)
if err != nil {
return "", xerrors.Errorf("commit log: %w", err)
}
// Extract PR numbers from commit titles and fetch metadata.
prMeta := ghBuildPullRequestMap(extractPRNumbers(commits))
// Section definitions in display order.
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"},
{"test", "Tests"},
{"build", "Build"},
{"ci", "CI"},
{"chore", "Chores"},
{"revert", "Reverts"},
{"other", "Other changes"},
{"experimental", "Experimental"},
}
// Categorize commits into sections.
buckets := make(map[string][]commitEntry)
for _, c := range commits {
// Skip dependabot commits.
if isDependabot(c.Title) {
continue
}
var labels []string
for _, prNum := range parsePRNumbers(c.Title) {
if meta, ok := prMeta[prNum]; ok {
labels = append(labels, meta.Labels...)
}
}
cat := categorizeCommit(c.Title, labels)
buckets[cat] = append(buckets[cat], c)
}
var b strings.Builder
// RC note based on version.
if newVersion.IsRC() {
_, _ = b.WriteString("> [!NOTE]\n")
_, _ = b.WriteString("> This is a **release candidate** build of Coder. Release candidate builds are not intended for production use. Learn more about our [Release Schedule](https://coder.com/docs/install/releases).\n\n")
}
_, _ = b.WriteString("## Changelog\n\n")
for _, sec := range sections {
entries, ok := buckets[sec.key]
if !ok || len(entries) == 0 {
continue
}
_, _ = fmt.Fprintf(&b, "### %s\n\n", sec.title)
for _, e := range entries {
title := humanizeTitle(e.Title)
if prNums := parsePRNumbers(e.Title); len(prNums) > 0 {
// Strip the trailing PR reference from the title since
// we add it as a link.
title = stripPRRef(title)
_, _ = fmt.Fprintf(&b, "- %s (#%d)\n", title, prNums[0])
} else {
_, _ = fmt.Fprintf(&b, "- %s\n", title)
}
}
_, _ = b.WriteString("\n")
}
// Compare link.
_, _ = fmt.Fprintf(&b, "Compare: [`%s...%s`](https://github.com/%s/%s/compare/%s...%s)\n\n",
previousVersion.String(), newVersion.String(),
owner, repo,
previousVersion.String(), newVersion.String())
// Container image.
_, _ = b.WriteString("## Container image\n\n")
_, _ = fmt.Fprintf(&b, "- `docker pull ghcr.io/%s/%s:%s`\n\n", owner, repo, newVersion.String())
// Install/upgrade links.
_, _ = b.WriteString("## Install/upgrade\n\n")
_, _ = b.WriteString("Refer to our docs to [install](https://coder.com/docs/install) or [upgrade](https://coder.com/docs/admin/upgrade) Coder, or use a release asset below.\n")
return b.String(), nil
}
// isDependabot returns true if the commit title looks like it came
// from dependabot.
func isDependabot(title string) bool {
lower := strings.ToLower(title)
return strings.Contains(lower, "dependabot") ||
strings.HasPrefix(lower, "chore(deps):")
}
// prNumRe matches GitHub's "(#NNN)" PR reference convention.
var prNumRe = regexp.MustCompile(`\(#(\d+)\)`)
// parsePRNumbers extracts all PR numbers from a commit title.
func parsePRNumbers(title string) []int {
var nums []int
for _, m := range prNumRe.FindAllStringSubmatch(title, -1) {
num, _ := strconv.Atoi(m[1])
nums = append(nums, num)
}
return nums
}
// extractPRNumbers collects all unique PR numbers from a list of commits.
func extractPRNumbers(commits []commitEntry) []int {
seen := make(map[int]bool)
var nums []int
for _, c := range commits {
for _, num := range parsePRNumbers(c.Title) {
if !seen[num] {
seen[num] = true
nums = append(nums, num)
}
}
}
return nums
}
// stripPRRef removes a trailing (#NNN) from a title.
func stripPRRef(title string) string {
if idx := strings.LastIndex(title, "(#"); idx >= 0 {
return strings.TrimSpace(title[:idx])
}
return title
}
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/xerrors"
)
// publishRelease creates a GitHub release with the given assets
// and generates checksums.
func publishRelease(versionTag string, stable bool, notesFile string, assets []string) error {
if len(assets) == 0 {
return xerrors.New("no assets provided")
}
// Validate all asset files exist.
for _, f := range assets {
if _, err := os.Stat(f); err != nil {
return xerrors.Errorf("asset not found: %s", f)
}
}
// Verify we're checked out on the expected tag.
described, err := gitOutput("describe", "--always")
if err != nil {
return xerrors.Errorf("git describe: %w", err)
}
if described != versionTag {
return xerrors.Errorf("checked-out ref %q does not match release tag %q", described, versionTag)
}
// Create a temp directory with symlinks to all assets.
tempDir, err := os.MkdirTemp("", "release-publish-*")
if err != nil {
return xerrors.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tempDir)
for _, f := range assets {
abs, err := filepath.Abs(f)
if err != nil {
return xerrors.Errorf("abs path for %s: %w", f, err)
}
if err := os.Symlink(abs, filepath.Join(tempDir, filepath.Base(f))); err != nil {
return xerrors.Errorf("symlink %s: %w", f, err)
}
}
// Generate checksums file.
version := strings.TrimPrefix(versionTag, "v")
checksumFile := fmt.Sprintf("coder_%s_checksums.txt", version)
checksumPath := filepath.Join(tempDir, checksumFile)
if err := generateChecksums(tempDir, checksumPath); err != nil {
return xerrors.Errorf("generate checksums: %w", err)
}
// Determine target commitish from release branch.
targetCommitish := "main"
branchRef, err := gitOutput("branch", "--remotes", "--contains", versionTag, "--format", "%(refname)", "*/release/*")
if err == nil && branchRef != "" {
// refs/remotes/origin/release/2.9 -> release/2.9
if idx := strings.Index(branchRef, "release/"); idx >= 0 {
targetCommitish = branchRef[idx:]
}
}
// Build gh release create arguments.
ghArgs := []string{
"release", "create",
"--repo", fmt.Sprintf("%s/%s", owner, repo),
"--title", versionTag,
"--target", targetCommitish,
"--notes-file", notesFile,
}
// RC detection from the version tag.
isRC := strings.Contains(versionTag, "-rc.")
switch {
case isRC:
ghArgs = append(ghArgs, "--prerelease", "--latest=false")
case stable:
ghArgs = append(ghArgs, "--latest=true")
default:
ghArgs = append(ghArgs, "--latest=false")
}
ghArgs = append(ghArgs, versionTag)
// Add all files from the temp directory.
entries, err := os.ReadDir(tempDir)
if err != nil {
return xerrors.Errorf("read temp dir: %w", err)
}
for _, e := range entries {
ghArgs = append(ghArgs, filepath.Join(tempDir, e.Name()))
}
cmd := exec.Command("gh", ghArgs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = strings.NewReader("") // prevent interactive prompts
if err := cmd.Run(); err != nil {
return xerrors.Errorf("gh release create: %w", err)
}
return nil
}
// generateChecksums writes SHA256 checksums for all files in dir
// (excluding the output file itself) to outPath.
func generateChecksums(dir, outPath string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
var lines []string
for _, e := range entries {
if e.IsDir() {
continue
}
path := filepath.Join(dir, e.Name())
hash, err := sha256File(path)
if err != nil {
return xerrors.Errorf("hash %s: %w", e.Name(), err)
}
lines = append(lines, fmt.Sprintf("%s %s", hash, e.Name()))
}
return os.WriteFile(outPath, []byte(strings.Join(lines, "\n")+"\n"), 0o600)
}
// sha256File returns the hex-encoded SHA256 hash of a file.
func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"fmt"
"regexp"
"strconv"
"strings"
"golang.org/x/xerrors"
)
// version represents a parsed semantic version with optional RC
// suffix. When rc < 0 the version is a final release. The original
// field preserves the string that was parsed (including the leading
// "v").
type version struct {
major int
minor int
patch int
rc int // -1 means not an RC
original string
}
// String returns the canonical version string (e.g. "v2.21.0" or
// "v2.21.0-rc.3").
func (v version) String() string {
if v.rc >= 0 {
return fmt.Sprintf("v%d.%d.%d-rc.%d", v.major, v.minor, v.patch, v.rc)
}
return fmt.Sprintf("v%d.%d.%d", v.major, v.minor, v.patch)
}
// IsRC returns true if this is a release candidate.
func (v version) IsRC() bool {
return v.rc >= 0
}
// semverRe matches vMAJOR.MINOR.PATCH with optional -rc.N suffix.
var semverRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$`)
// parseVersion parses a version string like "v2.21.0" or
// "v2.21.0-rc.3".
func parseVersion(s string) (version, error) {
m := semverRe.FindStringSubmatch(s)
if m == nil {
return version{}, xerrors.Errorf("invalid version %q", s)
}
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
patch, _ := strconv.Atoi(m[3])
rc := -1
if m[4] != "" {
rc, _ = strconv.Atoi(m[4])
}
// Preserve the original string with leading "v".
orig := s
if !strings.HasPrefix(orig, "v") {
orig = "v" + orig
}
return version{
major: major,
minor: minor,
patch: patch,
rc: rc,
original: orig,
}, nil
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_parseVersion(t *testing.T) {
t.Parallel()
tests := []struct {
input string
wantErr bool
want version
}{
{
input: "v2.21.0",
want: version{major: 2, minor: 21, patch: 0, rc: -1, original: "v2.21.0"},
},
{
input: "v2.21.0-rc.3",
want: version{major: 2, minor: 21, patch: 0, rc: 3, original: "v2.21.0-rc.3"},
},
{
input: "2.21.0",
want: version{major: 2, minor: 21, patch: 0, rc: -1, original: "v2.21.0"},
},
{
input: "v0.0.0",
want: version{major: 0, minor: 0, patch: 0, rc: -1, original: "v0.0.0"},
},
{
input: "v1.2.3-rc.0",
want: version{major: 1, minor: 2, patch: 3, rc: 0, original: "v1.2.3-rc.0"},
},
{
input: "not-a-version",
wantErr: true,
},
{
input: "",
wantErr: true,
},
{
input: "v1.2",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
got, err := parseVersion(tt.input)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.want.major, got.major, "major")
require.Equal(t, tt.want.minor, got.minor, "minor")
require.Equal(t, tt.want.patch, got.patch, "patch")
require.Equal(t, tt.want.rc, got.rc, "rc")
require.Equal(t, tt.want.original, got.original, "original")
})
}
}
func Test_versionString(t *testing.T) {
t.Parallel()
tests := []struct {
v version
want string
}{
{version{major: 2, minor: 21, patch: 0, rc: -1}, "v2.21.0"},
{version{major: 2, minor: 21, patch: 0, rc: 3}, "v2.21.0-rc.3"},
{version{major: 1, minor: 0, patch: 5, rc: -1}, "v1.0.5"},
{version{major: 1, minor: 0, patch: 0, rc: 0}, "v1.0.0-rc.0"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, tt.v.String())
})
}
}
func Test_versionIsRC(t *testing.T) {
t.Parallel()
require.True(t, version{rc: 0}.IsRC())
require.True(t, version{rc: 3}.IsRC())
require.False(t, version{rc: -1}.IsRC())
}