mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(¬esFile),
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user