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:
Garrett Delfosse
2026-07-01 16:20:00 -04:00
committed by GitHub
parent 4936ff9808
commit ff7e0bc193
14 changed files with 1763 additions and 313 deletions
+40 -29
View File
@@ -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)
}
+50
View File
@@ -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
View File
@@ -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, " ")
}
+126
View File
@@ -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)
})
}
}
+3 -3
View File
@@ -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] + ")"
}
+14 -23
View File
@@ -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...)
}
+6 -12
View File
@@ -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",
+58 -3
View File
@@ -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(&notesFile),
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)
},
},
},
+4 -4
View File
@@ -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 {
+94
View File
@@ -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
}
+187
View File
@@ -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")
}
+4 -9
View File
@@ -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)
}