refactor: consolidate release tooling into a single releaser command (#27034)

## What

Consolidates the two separate release programs into a single command at
`scripts/releaser`:

- `scripts/releaser/v1/` — the former interactive releaser (package
`v1`).
- `scripts/releaser/v2/` — the former `scripts/release-action` CI tool
(package `v2`).
- `scripts/releaser/main.go` — new entrypoint. Runs the **v2** tooling
by
  default and the **v1** interactive wizard with `--legacy`.

## CLI shape

Three documented subcommands, each backed by v2 `prepare-release` with
the
release type baked in:

- `releaser rc` — tag a release candidate
- `releaser branch` — cut a new release branch and tag its first RC
- `releaser release` — tag a stable release or patch

The former release-action verbs (`calculate-version`, `prepare-release`,
`generate-notes`, `publish`) are retained as **hidden** top-level
commands with
identical flags and stdout, so `tag-and-release.yaml` migrates with a
path-only
change (`scripts/release-action` -> `scripts/releaser`). `--legacy` runs
the v1
wizard and is mutually exclusive with the subcommands.
`scripts/release.sh` now
launches `releaser --legacy`.

All file moves are rename-detected by git, so the per-file diff is just
the
package declaration.

## Testing

- `go build ./scripts/...`, `go vet ./scripts/releaser/...`, `go test
./scripts/releaser/...`
- `golangci-lint run ./scripts/releaser/...`, `make lint/emdash`,
`shellcheck`, `actionlint`
- Smoke: `releaser --help` shows only rc/branch/release; hidden verbs
still run;
`releaser rc --ref main --dry-run` emits the same JSON contract;
`--legacy rc`
  errors cleanly.

<details>
<summary>Implementation plan</summary>

# Plan: Consolidate release tooling into a single `scripts/releaser`
command

## Goal

Merge the two separate release programs into one binary at
`scripts/releaser`:

- `scripts/releaser/v1/` — the current interactive releaser (package
`v1`).
- `scripts/releaser/v2/` — the current CI `scripts/release-action`
(package `v2`).
- `scripts/releaser/main.go` — new entrypoint (package `main`).
  - Uses v2 by default, v1 with `--legacy`.
- Exposes 3 subcommands: `rc`, `branch` (cut release branch), `release`.

## Design decision (Option A, chosen)

The workflow needs `prepare-release`, `generate-notes`, and `publish`
invokable
separately (a build happens between prepare and publish). The latter two
are
version-driven and type-agnostic, so they do not map cleanly onto
`rc`/`branch`/`release`.

- Visible subcommands `rc`, `branch`, `release` run v2 `prepare-release`
with the
  type baked in and print the same JSON.
- Hidden verbs `calculate-version`, `prepare-release`, `generate-notes`,
  `publish` keep byte-identical flags/stdout, so the workflow change is
  path-only. Lowest risk; honors "3 subcommands" from a UX perspective.

## `--legacy` semantics

- `releaser --legacy` runs the v1 interactive wizard (preserves today's
  behavior; the wizard auto-detects RC vs release from the branch).
- `--legacy` is mutually exclusive with the subcommands (clear error if
  combined), because v1 auto-detects type and cannot cut a branch.

## Work items

1. Create `v1` and `v2` packages via `git mv`, renaming `package main`.
Move the `owner`/`repo` consts into each package. Add `v1.Run(inv,
dryRun)`
   (old wizard `main()` body) and v2 command builders (`CICommands`,
   `TypeCommand`) so internals stay unexported.
2. New `scripts/releaser/main.go`: top-level `releaser` with `--legacy`,
the 3
subcommands, and the hidden compat verbs; delegates to `v1.Run` for
legacy.
3. Update references: `tag-and-release.yaml` (3 command paths + header
comment)
   and `scripts/release.sh` (`--legacy`).
4. Verify: build, vet, test, `go run` smoke tests, fmt, lint.
5. Open a single PR from a feature branch.

## Risks / notes

- stdout contract for rc/branch/release and the hidden verbs must stay
identical
  (workflow parses stdout); logs go to stderr.
- Patch releases from pre-existing `release/X.Y` branches run those
branches'
own (old) workflow + `scripts/release-action`, so they stay
self-consistent.
New releases cut from branches containing this change get the new
workflow +
`scripts/releaser`. No forwarding stub needed since code and workflow
ship
  together.

</details>

---

This PR was created by Coder Agents on behalf of @f0ssel.
This commit is contained in:
Garrett Delfosse
2026-07-07 11:13:50 -04:00
committed by GitHub
parent 90861ffa75
commit bfbacd64f4
29 changed files with 420 additions and 287 deletions
+56 -52
View File
@@ -2,76 +2,80 @@ package main
import (
"errors"
"fmt"
"os"
"os/exec"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
releaserv1 "github.com/coder/coder/v2/scripts/releaser/v1"
releaserv2 "github.com/coder/coder/v2/scripts/releaser/v2"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
const (
owner = "coder"
repo = "coder"
)
func main() {
var dryRun bool
var (
legacy bool
dryRun bool
)
// Default (v2) subcommands. rc, branch, and release run the
// non-interactive prepare-release logic with the release type baked
// in.
children := []*serpent.Command{
releaserv2.TypeCommand("rc", "Tag a release candidate from main or a release branch.", "rc"),
releaserv2.TypeCommand("branch", "Cut a new release branch and tag its first release candidate.", "create-release-branch"),
releaserv2.TypeCommand("release", "Tag a stable release or patch from a release branch.", "release"),
}
// Hidden compatibility verbs. These preserve the exact names, flags,
// and stdout contract of the former scripts/release-action tool so
// GitHub Actions workflows migrate with a path-only change.
for _, c := range releaserv2.CICommands() {
c.Hidden = true
children = append(children, c)
}
// --legacy selects the v1 interactive wizard and cannot be combined
// with a subcommand, which v1 does not understand.
for _, c := range children {
next := c.Handler
c.Handler = func(inv *serpent.Invocation) error {
if legacy {
return xerrors.New("--legacy cannot be combined with a subcommand; run 'releaser --legacy' for the interactive tool")
}
return next(inv)
}
}
cmd := &serpent.Command{
Use: "releaser",
Short: "Interactive release tagging for coder/coder.",
Long: "Tag RCs from main, releases/patches from release/X.Y. The tool detects the branch, infers the next version, and walks you through tagging, pushing, and triggering the release workflow.",
Use: "releaser <subcommand>",
Short: "Release tooling for coder/coder.",
Long: "Tag and publish releases for coder/coder.\n\n" +
"By default releaser runs the non-interactive tooling via the rc,\n" +
"branch, and release subcommands. Pass --legacy to run the older\n" +
"interactive release wizard instead.",
Options: serpent.OptionSet{
{
Name: "legacy",
Flag: "legacy",
Description: "Run the legacy interactive release wizard.",
Value: serpent.BoolOf(&legacy),
},
{
Name: "dry-run",
Flag: "dry-run",
Description: "Print write commands instead of executing them.",
Description: "Print mutating commands instead of executing them (legacy wizard only).",
Value: serpent.BoolOf(&dryRun),
},
},
Children: children,
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
w := inv.Stderr
// --- Check dependencies ---
if _, err := exec.LookPath("git"); err != nil {
return xerrors.New("git is required but not found in PATH")
if legacy {
return releaserv1.Run(inv, dryRun)
}
// --- Check GPG signing ---
signingKey, _ := gitOutput("config", "--get", "user.signingkey")
gpgFormat, _ := gitOutput("config", "--get", "gpg.format")
gpgConfigured := signingKey != "" || gpgFormat != ""
if !gpgConfigured {
warnf(w, "GPG signing is not configured. Tags will be unsigned — there will be no way to verify who pushed the tag.")
_, _ = fmt.Fprintf(w, " To fix: set git config user.signingkey or gpg.format\n")
if err := confirmWithDefault(inv, "Continue without signing?", cliui.ConfirmNo); err != nil {
return err
}
_, _ = fmt.Fprintln(w)
}
// --- Check gh CLI auth ---
ghAvailable := checkGHAuth()
if !ghAvailable {
warnf(w, "gh CLI is not available or not authenticated.")
infof(w, "Continuing without GitHub features (PR checks, label lookups, workflow trigger).")
_, _ = fmt.Fprintln(w)
}
// --- Wire up executor ---
var executor ReleaseExecutor
if dryRun {
outputPrefix = "[DRYRUN] "
executor = &dryRunExecutor{w: w}
} else {
executor = &liveExecutor{}
}
return runRelease(ctx, inv, executor, ghAvailable, gpgConfigured, dryRun)
// No subcommand given and not in legacy mode: show help.
return serpent.DefaultHelpFn()(inv)
},
}
@@ -80,8 +84,8 @@ func main() {
if errors.Is(err, cliui.ErrCanceled) {
os.Exit(1)
}
// Unwrap serpent's "running command ..." wrapper to
// keep output clean.
// Unwrap serpent's "running command ..." wrapper to keep output
// clean.
var runErr *serpent.RunCommandError
if errors.As(err, &runErr) {
err = runErr.Err
@@ -1,4 +1,4 @@
package main
package v1
import (
"regexp"
@@ -1,4 +1,4 @@
package main
package v1
import (
"fmt"
@@ -1,4 +1,4 @@
package main
package v1
import (
"context"
@@ -1,4 +1,4 @@
package main
package v1
import (
"errors"
@@ -1,4 +1,4 @@
package main
package v1
import (
"errors"
@@ -1,4 +1,4 @@
package main
package v1
import (
"context"
+67
View File
@@ -0,0 +1,67 @@
package v1
import (
"fmt"
"os/exec"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/serpent"
)
const (
owner = "coder"
repo = "coder"
)
// Run executes the legacy interactive release wizard.
//
// It mirrors the behavior of the original standalone releaser tool: it
// verifies dependencies, warns when GPG signing or the gh CLI are not
// configured, wires up a live or dry-run executor, and then walks the
// operator through tagging, pushing, and triggering the release
// workflow.
//
//nolint:revive // dryRun selects the dry-run executor for the wizard.
func Run(inv *serpent.Invocation, dryRun bool) error {
ctx := inv.Context()
w := inv.Stderr
// --- Check dependencies ---
if _, err := exec.LookPath("git"); err != nil {
return xerrors.New("git is required but not found in PATH")
}
// --- Check GPG signing ---
signingKey, _ := gitOutput("config", "--get", "user.signingkey")
gpgFormat, _ := gitOutput("config", "--get", "gpg.format")
gpgConfigured := signingKey != "" || gpgFormat != ""
if !gpgConfigured {
warnf(w, "GPG signing is not configured. Tags will be unsigned, so there will be no way to verify who pushed the tag.")
_, _ = fmt.Fprintf(w, " To fix: set git config user.signingkey or gpg.format\n")
if err := confirmWithDefault(inv, "Continue without signing?", cliui.ConfirmNo); err != nil {
return err
}
_, _ = fmt.Fprintln(w)
}
// --- Check gh CLI auth ---
ghAvailable := checkGHAuth()
if !ghAvailable {
warnf(w, "gh CLI is not available or not authenticated.")
infof(w, "Continuing without GitHub features (PR checks, label lookups, workflow trigger).")
_, _ = fmt.Fprintln(w)
}
// --- Wire up executor ---
var executor ReleaseExecutor
if dryRun {
outputPrefix = "[DRYRUN] "
executor = &dryRunExecutor{w: w}
} else {
executor = &liveExecutor{}
}
return runRelease(ctx, inv, executor, ghAvailable, gpgConfigured, dryRun)
}
@@ -1,4 +1,4 @@
package main
package v1
import (
"io"
@@ -1,4 +1,4 @@
package main
package v1
import (
"fmt"
@@ -1,4 +1,4 @@
package main
package v1 //nolint:testpackage // Tests unexported release helpers.
import (
"testing"
+453
View File
@@ -0,0 +1,453 @@
package v2
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(exec CommandExecutor, releaseType, ref, commitSHA string) (calculateResult, error) {
// Ensure we have up-to-date remote state. Fetching only updates
// local remote-tracking refs, so it runs even in dry-run mode to
// keep version calculation accurate.
if _, err := gitOutput(exec, "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(exec, ref, commitSHA)
}
return calculateRCFromBranchReleaseRequest(exec, 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(exec, ref)
case "create-release-branch":
if !isMain {
return nil, xerrors.Errorf("create-release-branch must be run from main, got %q", ref)
}
return calculateCreateBranchRequest(exec, 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(exec CommandExecutor, ref, commitSHA string) (string, error) {
if commitSHA != "" {
if !isHexSHA(commitSHA) {
return "", xerrors.Errorf("invalid commit SHA %q: must be a hex string", commitSHA)
}
// Resolve to a full commit SHA. The idempotency checks in
// createAndPushTag/createAndPushBranch compare targetRef
// against full SHAs (git rev-parse of an existing tag,
// ls-remote branch output), so a short SHA passed via the
// commit input would never match and would break re-runs.
sha, err := gitOutput(exec, "rev-parse", "--verify", commitSHA+"^{commit}")
if err != nil {
return "", xerrors.Errorf("resolve commit %s: %w", commitSHA, err)
}
return sha, nil
}
sha, err := gitOutput(exec, "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(exec CommandExecutor, ref, commitSHA string) (ReleaseRequest, error) {
targetRef, err := resolveCommit(exec, ref, commitSHA)
if err != nil {
return ReleaseRequest{}, err
}
// Verify commit is an ancestor of origin/main.
if err := gitRun(exec, "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(exec)
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(exec CommandExecutor, 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(exec, ref, commitSHA)
if err != nil {
return ReleaseRequest{}, err
}
// Fail if there are open PRs targeting this release branch.
if err := checkOpenPRs(exec, ref); err != nil {
return ReleaseRequest{}, err
}
allTags, err := listSemverTags(exec)
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(exec CommandExecutor, 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(exec, "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(exec, ref); err != nil {
return ReleaseRequest{}, err
}
allTags, err := listSemverTags(exec)
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(exec CommandExecutor, ref, commitSHA string) (CreateBranchRequest, error) {
targetRef, err := resolveCommit(exec, ref, commitSHA)
if err != nil {
return CreateBranchRequest{}, err
}
// Verify commit is an ancestor of origin/main.
if err := gitRun(exec, "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(exec)
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(exec, "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(exec CommandExecutor) ([]version, error) {
out, err := gitOutput(exec, "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
}
+477
View File
@@ -0,0 +1,477 @@
package v2 //nolint:testpackage // Tests unexported release helpers.
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))
})
}
}
func Test_resolveCommit(t *testing.T) {
t.Parallel()
t.Run("ShortSHAResolvedToFull", func(t *testing.T) {
t.Parallel()
const full = "1234567890abcdef1234567890abcdef12345678"
var gotArgs []string
mock := &mockExecutor{
RunOutputFunc: func(_ string, args ...string) (string, error) {
gotArgs = args
return full, nil
},
}
got, err := resolveCommit(mock, "main", "1234567")
require.NoError(t, err)
require.Equal(t, full, got)
require.Equal(t, []string{"rev-parse", "--verify", "1234567^{commit}"}, gotArgs)
})
t.Run("EmptyResolvesRefHead", func(t *testing.T) {
t.Parallel()
const full = "abcdef1234567890abcdef1234567890abcdef12"
var gotArgs []string
mock := &mockExecutor{
RunOutputFunc: func(_ string, args ...string) (string, error) {
gotArgs = args
return full, nil
},
}
got, err := resolveCommit(mock, "main", "")
require.NoError(t, err)
require.Equal(t, full, got)
require.Equal(t, []string{"rev-parse", "origin/main"}, gotArgs)
})
t.Run("InvalidSHANotResolved", func(t *testing.T) {
t.Parallel()
called := false
mock := &mockExecutor{
RunOutputFunc: func(_ string, _ ...string) (string, error) {
called = true
return "", nil
},
}
_, err := resolveCommit(mock, "main", "zzzzzzz")
require.Error(t, err)
require.False(t, called, "git should not be invoked for an invalid SHA")
})
}
+128
View File
@@ -0,0 +1,128 @@
package v2
import (
"bytes"
"errors"
"fmt"
"io"
"os/exec"
"strings"
"golang.org/x/xerrors"
)
// CommandExecutor abstracts running CLI commands so that a dry-run
// implementation can print commands instead of executing them.
//
// Read-only methods (RunOutput, Run) execute unconditionally in both
// real and dry-run modes. Mutating methods (RunMutation,
// RunMutationStdout) are printed instead of executed in dry-run mode.
// Callers must choose the correct method at the call site so that
// dry-run behavior is explicit and reviewable.
type CommandExecutor interface {
// RunOutput executes the named program with args and returns
// trimmed stdout. Use for read-only commands.
RunOutput(name string, args ...string) (string, error)
// Run executes the named program with args, discarding output.
// Use for read-only commands where only the exit code matters.
Run(name string, args ...string) error
// RunMutation executes a command that modifies remote state
// (e.g. git push, git tag). In dry-run mode it prints instead
// of executing.
RunMutation(name string, args ...string) error
// RunMutationStdout executes a mutating command, streaming
// stdout and stderr to the provided writers. Stdin is set to
// empty to prevent interactive prompts. In dry-run mode it
// prints instead of executing.
RunMutationStdout(stdout, stderr io.Writer, name string, args ...string) error
}
// realExecutor runs all commands for real via os/exec.
type realExecutor struct{}
func (realExecutor) RunOutput(name string, args ...string) (string, error) {
cmd := exec.Command(name, 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
}
func (realExecutor) Run(name string, args ...string) error {
cmd := exec.Command(name, args...)
return cmd.Run()
}
func (realExecutor) RunMutation(name string, args ...string) error {
cmd := exec.Command(name, args...)
// Capture stderr so that a failing mutation surfaces the command's
// error output (e.g. git's "fatal:" message) in the returned error
// instead of only the exit status.
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return xerrors.Errorf("%s: %w", msg, err)
}
return err
}
return nil
}
func (realExecutor) RunMutationStdout(stdout, stderr io.Writer, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Stdin = strings.NewReader("") // prevent interactive prompts
return cmd.Run()
}
// dryRunExecutor delegates read-only commands to the real executor
// and prints mutating commands instead of executing them.
type dryRunExecutor struct {
w io.Writer
real realExecutor
}
func newDryRunExecutor(w io.Writer) *dryRunExecutor {
return &dryRunExecutor{w: w}
}
func (d *dryRunExecutor) RunOutput(name string, args ...string) (string, error) {
return d.real.RunOutput(name, args...)
}
func (d *dryRunExecutor) Run(name string, args ...string) error {
return d.real.Run(name, args...)
}
func (d *dryRunExecutor) RunMutation(name string, args ...string) error {
_, _ = fmt.Fprintf(d.w, "[dry-run] would run: %s %s\n", name, shelljoin(args))
return nil
}
func (d *dryRunExecutor) RunMutationStdout(_, _ io.Writer, name string, args ...string) error {
_, _ = fmt.Fprintf(d.w, "[dry-run] would run: %s %s\n", name, shelljoin(args))
return nil
}
// shelljoin produces a shell-safe representation of args for display.
func shelljoin(args []string) string {
quoted := make([]string, len(args))
for i, a := range args {
if strings.ContainsAny(a, " \t\n\"'\\$") {
quoted[i] = "'" + strings.ReplaceAll(a, "'", "'\"'\"'") + "'"
continue
}
quoted[i] = a
}
return strings.Join(quoted, " ")
}
+134
View File
@@ -0,0 +1,134 @@
package v2 //nolint:testpackage // Tests unexported release helpers.
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRealExecutor_RunOutput(t *testing.T) {
t.Parallel()
exec := realExecutor{}
out, err := exec.RunOutput("echo", "hello")
require.NoError(t, err)
assert.Equal(t, "hello", out)
}
func TestRealExecutor_Run(t *testing.T) {
t.Parallel()
exec := realExecutor{}
err := exec.Run("true")
require.NoError(t, err)
err = exec.Run("false")
require.Error(t, err)
}
func TestRealExecutor_RunMutation(t *testing.T) {
t.Parallel()
exec := realExecutor{}
err := exec.RunMutation("true")
require.NoError(t, err)
err = exec.RunMutation("false")
require.Error(t, err)
}
func TestRealExecutor_RunMutationSurfacesStderr(t *testing.T) {
t.Parallel()
exec := realExecutor{}
err := exec.RunMutation("sh", "-c", "echo 'fatal: boom' 1>&2; exit 1")
require.Error(t, err)
assert.Contains(t, err.Error(), "fatal: boom")
}
func TestRealExecutor_RunMutationStdout(t *testing.T) {
t.Parallel()
exec := realExecutor{}
var stdout, stderr bytes.Buffer
err := exec.RunMutationStdout(&stdout, &stderr, "echo", "output")
require.NoError(t, err)
assert.Equal(t, "output\n", stdout.String())
}
func TestDryRunExecutor_RunOutputDelegates(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
exec := newDryRunExecutor(&buf)
// RunOutput should still execute (read-only commands).
out, err := exec.RunOutput("echo", "real-output")
require.NoError(t, err)
assert.Equal(t, "real-output", out)
assert.Empty(t, buf.String(), "RunOutput should not produce dry-run output")
}
func TestDryRunExecutor_RunDelegates(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
exec := newDryRunExecutor(&buf)
err := exec.Run("true")
require.NoError(t, err)
assert.Empty(t, buf.String(), "Run should not produce dry-run output")
}
func TestDryRunExecutor_RunMutationPrints(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
exec := newDryRunExecutor(&buf)
err := exec.RunMutation("git", "push", "origin", "refs/tags/v2.21.0:refs/tags/v2.21.0")
require.NoError(t, err)
assert.Contains(t, buf.String(), "[dry-run] would run: git push origin refs/tags/v2.21.0:refs/tags/v2.21.0")
}
func TestDryRunExecutor_RunMutationStdoutPrints(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
exec := newDryRunExecutor(&buf)
var stdout, stderr bytes.Buffer
err := exec.RunMutationStdout(&stdout, &stderr, "gh", "release", "create", "--repo", "coder/coder", "--title", "v2.21.0")
require.NoError(t, err)
assert.Empty(t, stdout.String(), "RunMutationStdout should not produce real output in dry-run")
assert.Contains(t, buf.String(), "[dry-run] would run: gh release create --repo coder/coder --title v2.21.0")
}
func TestDryRunExecutor_RunMutationStdoutQuotesArgs(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
exec := newDryRunExecutor(&buf)
var stdout, stderr bytes.Buffer
err := exec.RunMutationStdout(&stdout, &stderr, "gh", "release", "create", "--title", "has space")
require.NoError(t, err)
assert.Contains(t, buf.String(), "'has space'")
}
func TestShelljoin(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
want string
}{
{"simple", []string{"a", "b"}, "a b"},
{"space", []string{"a", "has space"}, "a 'has space'"},
// shelljoin uses POSIX single-quote escaping, so an embedded
// single quote becomes '"'"'.
{"quote", []string{"it's"}, `'it'"'"'s'`},
{"empty", []string{}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := shelljoin(tt.args)
assert.Equal(t, tt.want, got)
})
}
}
+264
View File
@@ -0,0 +1,264 @@
package v2
import (
"fmt"
"os"
"golang.org/x/xerrors"
"github.com/coder/serpent"
)
const (
owner = "coder"
repo = "coder"
)
// newExecutor returns the appropriate CommandExecutor based on the
// dry-run setting.
//
//nolint:revive // dryRun selects the dry-run executor.
func newExecutor(dryRun bool) CommandExecutor {
if dryRun {
return newDryRunExecutor(os.Stderr)
}
return realExecutor{}
}
// dryRunOption returns the shared --dry-run option bound to dryRun.
func dryRunOption(dryRun *bool) serpent.Option {
return serpent.Option{
Name: "dry-run",
Flag: "dry-run",
Description: "Print mutating commands instead of executing them.",
Value: serpent.BoolOf(dryRun),
}
}
// CICommands returns the low-level, CI-oriented release subcommands
// (calculate-version, prepare-release, generate-notes, publish). Their
// names, flags, and stdout output match the former scripts/release-action
// tool so GitHub Actions workflows can invoke them unchanged.
func CICommands() []*serpent.Command {
return []*serpent.Command{
calculateVersionCommand(),
prepareReleaseCommand(),
generateNotesCommand(),
publishCommand(),
}
}
// TypeCommand returns a command that runs prepare-release for a fixed
// release type. It backs the top-level rc, branch, and release
// subcommands, printing the same JSON as prepare-release.
func TypeCommand(use, short, releaseType string) *serpent.Command {
var (
ref string
commitSHA string
dryRun bool
)
return &serpent.Command{
Use: use,
Short: short,
Options: serpent.OptionSet{
{
Name: "ref",
Flag: "ref",
Description: "Git ref (branch name) to release from.",
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),
},
dryRunOption(&dryRun),
},
Handler: func(inv *serpent.Invocation) error {
result, err := prepareRelease(newExecutor(dryRun), releaseType, ref, commitSHA)
if err != nil {
return err
}
_, _ = fmt.Fprintln(inv.Stdout, result.String())
return nil
},
}
}
func calculateVersionCommand() *serpent.Command {
var (
releaseType string
ref string
commitSHA string
dryRun bool
)
return &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),
},
dryRunOption(&dryRun),
},
Handler: func(inv *serpent.Invocation) error {
result, err := calculateNextVersion(newExecutor(dryRun), releaseType, ref, commitSHA)
if err != nil {
return err
}
_, _ = fmt.Fprintln(inv.Stdout, result.String())
return nil
},
}
}
func prepareReleaseCommand() *serpent.Command {
var (
releaseType string
ref string
commitSHA string
dryRun bool
)
return &serpent.Command{
Use: "prepare-release",
Short: "Calculate version, create and push tag (and optionally release branch).",
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),
},
dryRunOption(&dryRun),
},
Handler: func(inv *serpent.Invocation) error {
result, err := prepareRelease(newExecutor(dryRun), releaseType, ref, commitSHA)
if err != nil {
return err
}
_, _ = fmt.Fprintln(inv.Stdout, result.String())
return nil
},
}
}
func generateNotesCommand() *serpent.Command {
var (
versionStr string
prevVersionStr string
dryRun bool
)
return &serpent.Command{
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,
},
dryRunOption(&dryRun),
},
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(newExecutor(dryRun), newVer, prevVer)
if err != nil {
return err
}
_, _ = fmt.Fprint(inv.Stdout, notes)
return nil
},
}
}
func publishCommand() *serpent.Command {
var (
versionStr string
stable bool
notesFile string
dryRun bool
)
return &serpent.Command{
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,
},
dryRunOption(&dryRun),
},
Handler: func(inv *serpent.Invocation) error {
assets := inv.Args
if len(assets) == 0 {
return xerrors.New("no asset files provided as arguments")
}
return publishRelease(newExecutor(dryRun), versionStr, stable, notesFile, assets)
},
}
}
+221
View File
@@ -0,0 +1,221 @@
package v2
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(exec CommandExecutor, commitRange string) ([]commitEntry, error) {
// Use --left-right --cherry-mark to identify equivalent
// (cherry-picked) commits and left-side-only commits.
out, err := gitOutput(exec, "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 v2 //nolint:testpackage // Tests unexported release helpers.
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))
})
}
}
+20
View File
@@ -0,0 +1,20 @@
package v2
// gitOutput runs a read-only git command and returns trimmed stdout.
func gitOutput(exec CommandExecutor, args ...string) (string, error) {
return exec.RunOutput("git", args...)
}
// gitRun runs a read-only git command, discarding stdout/stderr.
// Use this for commands where only the exit code matters (e.g.
// merge-base --is-ancestor).
func gitRun(exec CommandExecutor, args ...string) error {
return exec.Run("git", args...)
}
// gitMutate runs a git command that modifies remote state (e.g.
// push, tag). In dry-run mode the command is printed instead of
// executed.
func gitMutate(exec CommandExecutor, args ...string) error {
return exec.RunMutation("git", args...)
}
+109
View File
@@ -0,0 +1,109 @@
package v2
import (
"encoding/json"
"fmt"
"os"
"strings"
"golang.org/x/xerrors"
)
// ghOutput runs a gh CLI command and returns trimmed stdout.
func ghOutput(exec CommandExecutor, args ...string) (string, error) {
return exec.RunOutput("gh", args...)
}
// 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(exec CommandExecutor, prNumbers []int) pullRequestMap {
m := make(pullRequestMap)
for _, prNum := range prNumbers {
out, err := ghOutput(exec, "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(exec CommandExecutor, branch string) error {
out, err := ghOutput(exec, "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())
}
+160
View File
@@ -0,0 +1,160 @@
package v2
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(exec CommandExecutor, 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(exec, "rev-parse", "--verify", newTag); err != nil {
commitRange = fmt.Sprintf("%s..HEAD", previousVersion.String())
}
commits, err := commitLog(exec, commitRange)
if err != nil {
return "", xerrors.Errorf("commit log: %w", err)
}
// Extract PR numbers from commit titles and fetch metadata.
prMeta := ghBuildPullRequestMap(exec, 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
}
+94
View File
@@ -0,0 +1,94 @@
package v2
import (
"fmt"
"os"
"strings"
"golang.org/x/xerrors"
)
// prepareRelease computes the next release version, then creates and
// pushes the annotated tag and (optionally) the release branch.
// It emits the same JSON as calculateNextVersion so the workflow
// can consume it identically.
func prepareRelease(exec CommandExecutor, releaseType, ref, commitSHA string) (calculateResult, error) {
result, err := calculateNextVersion(exec, releaseType, ref, commitSHA)
if err != nil {
return nil, err
}
switch v := result.(type) {
case CreateBranchRequest:
if err := createAndPushTag(exec, v.Version, v.TargetRef); err != nil {
return nil, err
}
if err := createAndPushBranch(exec, v.BranchName, v.TargetRef); err != nil {
return nil, err
}
case ReleaseRequest:
if err := createAndPushTag(exec, v.Version, v.TargetRef); err != nil {
return nil, err
}
default:
return nil, xerrors.Errorf("unexpected result type %T", result)
}
return result, nil
}
// createAndPushTag creates an annotated tag at targetRef and pushes
// it. If the tag already exists at the correct commit, it is a
// no-op. If it exists at a different commit, it returns an error.
func createAndPushTag(exec CommandExecutor, versionTag, targetRef string) error {
// Check if the tag already exists locally. Dereference the tag
// object to the underlying commit with ^{}.
existing, err := gitOutput(exec, "rev-parse", "--verify", fmt.Sprintf("refs/tags/%s^{}", versionTag))
if err == nil {
if existing == targetRef {
_, _ = fmt.Fprintf(os.Stderr, "tag %s already exists at %s, skipping\n", versionTag, targetRef)
return nil
}
return xerrors.Errorf("tag %s already exists at %s, expected %s", versionTag, existing, targetRef)
}
// Create annotated tag.
if err := gitMutate(exec, "tag", "-a", versionTag, "-m", fmt.Sprintf("Release %s", versionTag), targetRef); err != nil {
return xerrors.Errorf("create tag %s: %w", versionTag, err)
}
// Push tag using explicit refspec.
refspec := fmt.Sprintf("refs/tags/%s:refs/tags/%s", versionTag, versionTag)
if err := gitMutate(exec, "push", "origin", refspec); err != nil {
return xerrors.Errorf("push tag %s: %w", versionTag, err)
}
return nil
}
// createAndPushBranch creates a branch at targetRef and pushes it.
// If the branch already exists at the correct commit on the remote,
// it is a no-op. If it exists at a different commit, it returns an
// error.
func createAndPushBranch(exec CommandExecutor, branchName, targetRef string) error {
// Check if the branch already exists on the remote.
existing, err := gitOutput(exec, "ls-remote", "--exit-code", "origin", fmt.Sprintf("refs/heads/%s", branchName))
if err == nil && existing != "" {
// ls-remote output format: "<sha>\trefs/heads/<branch>"
remoteSHA, _, _ := strings.Cut(existing, "\t")
if remoteSHA == targetRef {
_, _ = fmt.Fprintf(os.Stderr, "branch %s already exists at %s, skipping\n", branchName, targetRef)
return nil
}
return xerrors.Errorf("branch %s already exists at %s, expected %s", branchName, remoteSHA, targetRef)
}
// Push the commit directly to create the remote branch, without
// needing a local branch.
refspec := fmt.Sprintf("%s:refs/heads/%s", targetRef, branchName)
if err := gitMutate(exec, "push", "origin", refspec); err != nil {
return xerrors.Errorf("push branch %s: %w", branchName, err)
}
return nil
}
+187
View File
@@ -0,0 +1,187 @@
package v2 //nolint:testpackage // Tests unexported release helpers.
import (
"bytes"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockExecutor records mutating calls and delegates read-only calls
// to configurable functions.
type mockExecutor struct {
// MutationCalls records all calls to RunMutation and
// RunMutationStdout as "name arg1 arg2 ..." strings.
MutationCalls []string
// RunOutputFunc is called for RunOutput. If nil, returns ("", error).
RunOutputFunc func(name string, args ...string) (string, error)
// RunFunc is called for Run. If nil, returns nil.
RunFunc func(name string, args ...string) error
}
func (m *mockExecutor) RunOutput(name string, args ...string) (string, error) {
if m.RunOutputFunc != nil {
return m.RunOutputFunc(name, args...)
}
return "", nil
}
func (m *mockExecutor) Run(name string, args ...string) error {
if m.RunFunc != nil {
return m.RunFunc(name, args...)
}
return nil
}
func (m *mockExecutor) RunMutation(name string, args ...string) error {
call := name
for _, a := range args {
call += " " + a
}
m.MutationCalls = append(m.MutationCalls, call)
return nil
}
func (m *mockExecutor) RunMutationStdout(_, _ io.Writer, name string, args ...string) error {
call := name
for _, a := range args {
call += " " + a
}
m.MutationCalls = append(m.MutationCalls, call)
return nil
}
func TestCreateAndPushTag_New(t *testing.T) {
t.Parallel()
mock := &mockExecutor{
RunOutputFunc: func(name string, args ...string) (string, error) {
// Simulate tag not existing: git rev-parse --verify fails.
if len(args) >= 2 && args[0] == "rev-parse" && args[1] == "--verify" {
return "", assert.AnError
}
return "", nil
},
}
err := createAndPushTag(mock, "v2.21.0", "abc123")
require.NoError(t, err)
require.Len(t, mock.MutationCalls, 2)
assert.Contains(t, mock.MutationCalls[0], "git tag -a v2.21.0 -m Release v2.21.0 abc123")
assert.Contains(t, mock.MutationCalls[1], "git push origin refs/tags/v2.21.0:refs/tags/v2.21.0")
}
func TestCreateAndPushTag_AlreadyExistsMatching(t *testing.T) {
t.Parallel()
mock := &mockExecutor{
RunOutputFunc: func(name string, args ...string) (string, error) {
// Simulate tag existing at the correct commit.
if len(args) >= 2 && args[0] == "rev-parse" && args[1] == "--verify" {
return "abc123", nil
}
return "", nil
},
}
err := createAndPushTag(mock, "v2.21.0", "abc123")
require.NoError(t, err)
// No mutations should happen.
assert.Empty(t, mock.MutationCalls)
}
func TestCreateAndPushTag_AlreadyExistsMismatch(t *testing.T) {
t.Parallel()
mock := &mockExecutor{
RunOutputFunc: func(name string, args ...string) (string, error) {
if len(args) >= 2 && args[0] == "rev-parse" && args[1] == "--verify" {
return "different_sha", nil
}
return "", nil
},
}
err := createAndPushTag(mock, "v2.21.0", "abc123")
require.Error(t, err)
assert.Contains(t, err.Error(), "already exists at different_sha")
assert.Empty(t, mock.MutationCalls)
}
func TestCreateAndPushBranch_New(t *testing.T) {
t.Parallel()
mock := &mockExecutor{
RunOutputFunc: func(name string, args ...string) (string, error) {
// Simulate branch not existing: ls-remote fails.
if len(args) >= 1 && args[0] == "ls-remote" {
return "", assert.AnError
}
return "", nil
},
}
err := createAndPushBranch(mock, "release/2.21", "abc123")
require.NoError(t, err)
require.Len(t, mock.MutationCalls, 1)
assert.Contains(t, mock.MutationCalls[0], "git push origin abc123:refs/heads/release/2.21")
}
func TestCreateAndPushBranch_AlreadyExistsMatching(t *testing.T) {
t.Parallel()
mock := &mockExecutor{
RunOutputFunc: func(name string, args ...string) (string, error) {
if len(args) >= 1 && args[0] == "ls-remote" {
return "abc123\trefs/heads/release/2.21", nil
}
return "", nil
},
}
err := createAndPushBranch(mock, "release/2.21", "abc123")
require.NoError(t, err)
assert.Empty(t, mock.MutationCalls)
}
func TestCreateAndPushBranch_AlreadyExistsMismatch(t *testing.T) {
t.Parallel()
mock := &mockExecutor{
RunOutputFunc: func(name string, args ...string) (string, error) {
if len(args) >= 1 && args[0] == "ls-remote" {
return "other_sha\trefs/heads/release/2.21", nil
}
return "", nil
},
}
err := createAndPushBranch(mock, "release/2.21", "abc123")
require.Error(t, err)
assert.Contains(t, err.Error(), "already exists at other_sha")
assert.Empty(t, mock.MutationCalls)
}
func TestDryRunExecutor_SkipsMutations(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
exec := newDryRunExecutor(&buf)
// RunMutation should print, not execute.
err := exec.RunMutation("git", "tag", "-a", "v2.21.0", "-m", "Release v2.21.0", "abc123")
require.NoError(t, err)
assert.Contains(t, buf.String(), "[dry-run] would run: git tag -a v2.21.0")
buf.Reset()
err = exec.RunMutation("git", "push", "origin", "refs/tags/v2.21.0:refs/tags/v2.21.0")
require.NoError(t, err)
assert.Contains(t, buf.String(), "[dry-run] would run: git push origin refs/tags/v2.21.0:refs/tags/v2.21.0")
}
+148
View File
@@ -0,0 +1,148 @@
package v2
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"golang.org/x/xerrors"
)
// publishRelease creates a GitHub release with the given assets
// and generates checksums.
func publishRelease(exec CommandExecutor, 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(exec, "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(exec, "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()))
}
if err := exec.RunMutationStdout(os.Stdout, os.Stderr, "gh", ghArgs...); 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 v2
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 v2 //nolint:testpackage // Tests unexported release helpers.
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())
}