Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
374 changes: 144 additions & 230 deletions 374 .github/workflows/release.yaml

Large diffs are not rendered by default.

919 changes: 919 additions & 0 deletions 919 .github/workflows/tag-and-release.yaml

Large diffs are not rendered by default.

69 changes: 40 additions & 29 deletions 69 scripts/release-action/calculate.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ var branchRe = regexp.MustCompile(`^release/(\d+)\.(\d+)$`)
// 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 {
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)
}

Expand All @@ -66,21 +68,21 @@ func calculateNextVersion(releaseType, ref, commitSHA string) (calculateResult,
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 calculateRCFromMainReleaseRequest(exec, ref, commitSHA)
}
return calculateRCFromBranchReleaseRequest(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(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(ref, commitSHA)
return calculateCreateBranchRequest(exec, ref, commitSHA)

default:
return nil, xerrors.Errorf("unknown release type %q (expected rc, release, or create-release-branch)", releaseType)
Expand All @@ -90,33 +92,42 @@ func calculateNextVersion(releaseType, ref, commitSHA string) (calculateResult,
// 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) {
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)
}
return commitSHA, nil
// 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("rev-parse", fmt.Sprintf("origin/%s", ref))
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(ref, commitSHA string) (ReleaseRequest, error) {
targetRef, err := resolveCommit(ref, commitSHA)
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("merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil {
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()
allTags, err := listSemverTags(exec)
if err != nil {
return ReleaseRequest{}, err
}
Expand Down Expand Up @@ -158,7 +169,7 @@ func calculateRCFromMainReleaseRequest(ref, commitSHA string) (ReleaseRequest, e
}

// calculateRCFromBranchReleaseRequest tags an RC from the tip of a release branch.
func calculateRCFromBranchReleaseRequest(ref, commitSHA string) (ReleaseRequest, error) {
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)
Expand All @@ -167,17 +178,17 @@ func calculateRCFromBranchReleaseRequest(ref, commitSHA string) (ReleaseRequest,
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])

targetRef, err := resolveCommit(ref, commitSHA)
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(ref); err != nil {
if err := checkOpenPRs(exec, ref); err != nil {
return ReleaseRequest{}, err
}

allTags, err := listSemverTags()
allTags, err := listSemverTags(exec)
if err != nil {
return ReleaseRequest{}, err
}
Expand Down Expand Up @@ -215,7 +226,7 @@ func calculateRCFromBranchReleaseRequest(ref, commitSHA string) (ReleaseRequest,

// createRegularReleaseRequest calculates the next release (non-RC) version from
// a release branch. Uses HEAD of the branch.
func createRegularReleaseRequest(ref string) (ReleaseRequest, error) {
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)
Expand All @@ -225,17 +236,17 @@ func createRegularReleaseRequest(ref string) (ReleaseRequest, error) {
minor, _ := strconv.Atoi(m[2])

// Resolve branch HEAD.
headSHA, err := gitOutput("rev-parse", fmt.Sprintf("origin/%s", ref))
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(ref); err != nil {
if err := checkOpenPRs(exec, ref); err != nil {
return ReleaseRequest{}, err
}

allTags, err := listSemverTags()
allTags, err := listSemverTags(exec)
if err != nil {
return ReleaseRequest{}, err
}
Expand Down Expand Up @@ -264,18 +275,18 @@ func createRegularReleaseRequest(ref string) (ReleaseRequest, error) {

// 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)
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("merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil {
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()
allTags, err := listSemverTags(exec)
if err != nil {
return CreateBranchRequest{}, err
}
Expand All @@ -291,7 +302,7 @@ func calculateCreateBranchRequest(ref, commitSHA string) (CreateBranchRequest, e
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 {
if _, err := gitOutput(exec, "rev-parse", "--verify", fmt.Sprintf("origin/%s", branchName)); err == nil {
return CreateBranchRequest{}, xerrors.Errorf("branch %s already exists", branchName)
}

Expand Down Expand Up @@ -417,8 +428,8 @@ func versionIsLess(a, b version) bool {
}

// listSemverTags returns all semver tags from the repo.
func listSemverTags() ([]version, error) {
out, err := gitOutput("tag", "--list", "v*")
func listSemverTags(exec CommandExecutor) ([]version, error) {
out, err := gitOutput(exec, "tag", "--list", "v*")
if err != nil {
return nil, xerrors.Errorf("list tags: %w", err)
}
Expand Down
50 changes: 50 additions & 0 deletions 50 scripts/release-action/calculate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,53 @@ func Test_isHexSHA(t *testing.T) {
})
}
}

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")
})
}
114 changes: 114 additions & 0 deletions 114 scripts/release-action/cmdexec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package main

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit The parameter name exec shadows the os/exec stdlib package (Style Reviewer)

Used consistently across every file (func gitOutput(exec CommandExecutor, ...), etc.), so this is a deliberate pattern choice rather than an accident. In cmdexec.go itself, os/exec is imported, so exec the parameter and exec the package coexist in the same file. A name like ce or cmdExec would avoid the ambiguity. Low priority since the pattern is consistent.


import (
"errors"
"fmt"
"io"
"os/exec"
"strings"
)

// 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
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 RunOutput swallows stderr, making command failures opaque (Contract Auditor P2, Go Architect Obs)

RunOutput calls cmd.Output() and on error returns only the *exec.ExitError. The caller gets an opaque exit code with no stderr context. Failures in git fetch, git rev-parse, git ls-remote, etc. will produce errors like "exit status 128" with no indication of why.

This is pre-existing behavior (same code was in git.go before the refactoring), so not a regression. But the new abstraction is a good opportunity to attach stderr to the error. cmd.Output() already populates ExitError.Stderr in Go; consider including it in the error message.

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...)
return cmd.Run()
}

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, " ")
}
Loading
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.