mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add dry-run flag via CommandExecutor interface (#26422)
## Summary Adds a `--dry-run` capability to the `release-action` Go tool and exposes it through a **new** manual workflow, `tag-and-release.yaml`, without disturbing the existing `release.yaml` pipeline. PR #25162 had rewritten `release.yaml` in place to be driven by `scripts/release-action`, which changed its `workflow_dispatch` inputs from `release_channel`/`release_notes`/`dry_run` to `release_type`/`commit_sha`. That broke `scripts/releaser`, which dispatches `release.yaml` with the original inputs. This PR restores `release.yaml` and moves the Go-driven pipeline to its own workflow. ## Workflow layout after this PR | Workflow | Trigger | Driven by | Purpose | |---|---|---|---| | `release.yaml` | `scripts/releaser` (`gh workflow run`) | legacy inline shell | Existing pipeline, restored to pre-#25162 state | | `tag-and-release.yaml` | Manual (Actions UI) | `scripts/release-action` Go tool | New pipeline with `prepare-release` + `dry_run` | `release.yaml` is restored byte-for-byte to its pre-#25162 version, so its inputs match what `scripts/releaser` sends again. ## `release-action` design ### CommandExecutor interface Abstracts CLI command execution behind read-only and mutating methods: | Method | Purpose | Dry-run behavior | |---|---|---| | `RunOutput` | Read-only, capture stdout | Executes normally | | `Run` | Read-only, exit code only | Executes normally | | `RunMutation` | Changes remote state, no output | **Prints command, skips execution** | | `RunMutationStdout` | Changes remote state, streaming I/O | **Prints command, skips execution** | Two implementations: `realExecutor` (executes via `os/exec`) and `dryRunExecutor` (delegates read-only calls, prints mutating calls). ### `prepare-release` subcommand Composes `calculateNextVersion` with idempotent tag and branch creation+push, emitting the same JSON as `calculate-version`. Matching existing refs are skipped; mismatched refs error. ### `tag-and-release.yaml` `dry_run` input When enabled: `prepare-release` runs with `--dry-run` (version calculated, plan printed, nothing pushed), notes are generated for inspection, and the build+publish job is skipped via an `if` guard (cascading to homebrew/winget/docs). ## Mutating commands covered by `--dry-run` | Command | Call site | |---|---| | `git tag -a <version> ...` | `createAndPushTag` | | `git push origin refs/tags/...` | `createAndPushTag` | | `git push origin <sha>:refs/heads/...` | `createAndPushBranch` | | `gh release create ...` | `publishRelease` | `git fetch --tags --force origin` is intentionally not a mutation; it only updates local remote-tracking refs and must run for accurate version calculation. ## Changes - **New**: `scripts/release-action/cmdexec.go`, `prepare.go` (+ tests) - **Refactored**: `git.go`, `github.go`, `calculate.go`, `notes.go`, `commit.go`, `publish.go` to thread `CommandExecutor`; added `gitMutate` - **Updated**: `main.go` adds `--dry-run` flag and `prepare-release` subcommand - **New**: `.github/workflows/tag-and-release.yaml` (manual, Go-driven, with `dry_run`) - **Reverted**: `.github/workflows/release.yaml` to its pre-#25162 state > [!NOTE] > Generated by Coder Agents on behalf of @f0ssel
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -90,14 +92,23 @@ 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)
|
||||
}
|
||||
@@ -105,18 +116,18 @@ func resolveCommit(ref, commitSHA string) (string, error) {
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
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
|
||||
}
|
||||
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, " ")
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
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_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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,10 @@ var humanizedAreas = []struct {
|
||||
// commitLog returns non-merge commits in the given range, filtering
|
||||
// out left-side commits (already in the base) and deduplicating
|
||||
// cherry-picks using git's --cherry-mark.
|
||||
func commitLog(commitRange string) ([]commitEntry, error) {
|
||||
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("log", "--no-merges", "--left-right", "--cherry-mark",
|
||||
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
|
||||
@@ -106,7 +106,7 @@ func commitLog(commitRange string) ([]commitEntry, error) {
|
||||
}
|
||||
|
||||
// Normalize cherry-pick bot titles:
|
||||
// "chore: foo (cherry-pick #42) (#43)" → "chore: foo (#42)"
|
||||
// "chore: foo (cherry-pick #42) (#43)" -> "chore: foo (#42)"
|
||||
if m := cherryPickPRRe.FindStringSubmatch(title); m != nil {
|
||||
title = title[:cherryPickPRRe.FindStringIndex(title)[0]] + "(#" + m[1] + ")"
|
||||
}
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// gitOutput runs a read-only git command and returns trimmed stdout.
|
||||
func gitOutput(args ...string) (string, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", exitErr
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
func gitOutput(exec CommandExecutor, args ...string) (string, error) {
|
||||
return exec.RunOutput("git", args...)
|
||||
}
|
||||
|
||||
// gitRun runs a git command, discarding stdout/stderr. Use this
|
||||
// for commands where only the exit code matters (e.g. merge-base
|
||||
// --is-ancestor).
|
||||
func gitRun(args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
return cmd.Run()
|
||||
// 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...)
|
||||
}
|
||||
|
||||
@@ -4,20 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// ghOutput runs a gh CLI command and returns trimmed stdout.
|
||||
func ghOutput(args ...string) (string, error) {
|
||||
cmd := exec.Command("gh", args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
func ghOutput(exec CommandExecutor, args ...string) (string, error) {
|
||||
return exec.RunOutput("gh", args...)
|
||||
}
|
||||
|
||||
// pullRequest holds metadata about a GitHub pull request.
|
||||
@@ -34,11 +28,11 @@ type pullRequestMap map[int]pullRequest
|
||||
|
||||
// ghBuildPullRequestMap builds a map of PR number to metadata by
|
||||
// querying the GitHub API via the gh CLI for the given PR numbers.
|
||||
func ghBuildPullRequestMap(prNumbers []int) pullRequestMap {
|
||||
func ghBuildPullRequestMap(exec CommandExecutor, prNumbers []int) pullRequestMap {
|
||||
m := make(pullRequestMap)
|
||||
|
||||
for _, prNum := range prNumbers {
|
||||
out, err := ghOutput("pr", "view", fmt.Sprintf("%d", prNum),
|
||||
out, err := ghOutput(exec, "pr", "view", fmt.Sprintf("%d", prNum),
|
||||
"--repo", fmt.Sprintf("%s/%s", owner, repo),
|
||||
"--json", "number,labels,author")
|
||||
if err != nil {
|
||||
@@ -78,8 +72,8 @@ func ghBuildPullRequestMap(prNumbers []int) pullRequestMap {
|
||||
// checkOpenPRs verifies that no pull requests are open against the
|
||||
// given branch. If any are found, it returns an error listing them
|
||||
// with instructions to merge or close before releasing.
|
||||
func checkOpenPRs(branch string) error {
|
||||
out, err := ghOutput("pr", "list",
|
||||
func checkOpenPRs(exec CommandExecutor, branch string) error {
|
||||
out, err := ghOutput(exec, "pr", "list",
|
||||
"--repo", fmt.Sprintf("%s/%s", owner, repo),
|
||||
"--base", branch,
|
||||
"--state", "open",
|
||||
|
||||
@@ -24,8 +24,25 @@ func main() {
|
||||
prevVersionStr string
|
||||
notesFile string
|
||||
stable bool
|
||||
dryRun bool
|
||||
)
|
||||
|
||||
dryRunOption := serpent.Option{
|
||||
Name: "dry-run",
|
||||
Flag: "dry-run",
|
||||
Description: "Print mutating commands instead of executing them.",
|
||||
Value: serpent.BoolOf(&dryRun),
|
||||
}
|
||||
|
||||
// newExecutor returns the appropriate CommandExecutor based on
|
||||
// the --dry-run flag.
|
||||
newExecutor := func() CommandExecutor {
|
||||
if dryRun {
|
||||
return newDryRunExecutor(os.Stderr)
|
||||
}
|
||||
return realExecutor{}
|
||||
}
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "release-action <subcommand>",
|
||||
Short: "Non-interactive, CI-oriented release tool for coder/coder.",
|
||||
@@ -54,9 +71,45 @@ func main() {
|
||||
Description: "Commit SHA to tag (defaults to HEAD of --ref if empty).",
|
||||
Value: serpent.StringOf(&commitSHA),
|
||||
},
|
||||
dryRunOption,
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
result, err := calculateNextVersion(releaseType, ref, commitSHA)
|
||||
result, err := calculateNextVersion(newExecutor(), releaseType, ref, commitSHA)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintln(inv.Stdout, result.String())
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
result, err := prepareRelease(newExecutor(), releaseType, ref, commitSHA)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -82,6 +135,7 @@ func main() {
|
||||
Value: serpent.StringOf(&prevVersionStr),
|
||||
Required: true,
|
||||
},
|
||||
dryRunOption,
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
newVer, err := parseVersion(versionStr)
|
||||
@@ -92,7 +146,7 @@ func main() {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse --previous-version: %w", err)
|
||||
}
|
||||
notes, err := generateReleaseNotes(newVer, prevVer)
|
||||
notes, err := generateReleaseNotes(newExecutor(), newVer, prevVer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -124,13 +178,14 @@ func main() {
|
||||
Value: serpent.StringOf(¬esFile),
|
||||
Required: true,
|
||||
},
|
||||
dryRunOption,
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
assets := inv.Args
|
||||
if len(assets) == 0 {
|
||||
return xerrors.New("no asset files provided as arguments")
|
||||
}
|
||||
return publishRelease(versionStr, stable, notesFile, assets)
|
||||
return publishRelease(newExecutor(), versionStr, stable, notesFile, assets)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,22 +11,22 @@ import (
|
||||
|
||||
// generateReleaseNotes produces markdown release notes for the given
|
||||
// version range by examining the commit log and PR metadata.
|
||||
func generateReleaseNotes(newVersion, previousVersion version) (string, error) {
|
||||
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("rev-parse", "--verify", newTag); err != nil {
|
||||
if err := gitRun(exec, "rev-parse", "--verify", newTag); err != nil {
|
||||
commitRange = fmt.Sprintf("%s..HEAD", previousVersion.String())
|
||||
}
|
||||
|
||||
commits, err := commitLog(commitRange)
|
||||
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(extractPRNumbers(commits))
|
||||
prMeta := ghBuildPullRequestMap(exec, extractPRNumbers(commits))
|
||||
|
||||
// Section definitions in display order.
|
||||
type section struct {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -15,7 +14,7 @@ import (
|
||||
|
||||
// publishRelease creates a GitHub release with the given assets
|
||||
// and generates checksums.
|
||||
func publishRelease(versionTag string, stable bool, notesFile string, assets []string) error {
|
||||
func publishRelease(exec CommandExecutor, versionTag string, stable bool, notesFile string, assets []string) error {
|
||||
if len(assets) == 0 {
|
||||
return xerrors.New("no assets provided")
|
||||
}
|
||||
@@ -28,7 +27,7 @@ func publishRelease(versionTag string, stable bool, notesFile string, assets []s
|
||||
}
|
||||
|
||||
// Verify we're checked out on the expected tag.
|
||||
described, err := gitOutput("describe", "--always")
|
||||
described, err := gitOutput(exec, "describe", "--always")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("git describe: %w", err)
|
||||
}
|
||||
@@ -63,7 +62,7 @@ func publishRelease(versionTag string, stable bool, notesFile string, assets []s
|
||||
|
||||
// Determine target commitish from release branch.
|
||||
targetCommitish := "main"
|
||||
branchRef, err := gitOutput("branch", "--remotes", "--contains", versionTag, "--format", "%(refname)", "*/release/*")
|
||||
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 {
|
||||
@@ -102,11 +101,7 @@ func publishRelease(versionTag string, stable bool, notesFile string, assets []s
|
||||
ghArgs = append(ghArgs, filepath.Join(tempDir, e.Name()))
|
||||
}
|
||||
|
||||
cmd := exec.Command("gh", ghArgs...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = strings.NewReader("") // prevent interactive prompts
|
||||
if err := cmd.Run(); err != nil {
|
||||
if err := exec.RunMutationStdout(os.Stdout, os.Stderr, "gh", ghArgs...); err != nil {
|
||||
return xerrors.Errorf("gh release create: %w", err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user