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

Commit 8b44bd6

Browse filesBrowse files
feat: add dry-run flag via CommandExecutor interface (#26422) (#27069)
Cherry-pick of #26422 Original PR: #26422 — feat: add dry-run flag via CommandExecutor interface Merge commit: ff7e0bc Requested by: @f0ssel --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Garrett Delfosse <garrett@coder.com>
1 parent a82f235 commit 8b44bd6
Copy full SHA for 8b44bd6

14 files changed

+1,761-311Lines changed: 1761 additions & 311 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎.github/workflows/release.yaml‎

Copy file name to clipboardExpand all lines: .github/workflows/release.yaml
+142-228Lines changed: 142 additions & 228 deletions
Large diffs are not rendered by default.
Collapse file

‎.github/workflows/tag-and-release.yaml‎

Copy file name to clipboardExpand all lines: .github/workflows/tag-and-release.yaml
+919Lines changed: 919 additions & 0 deletions
Large diffs are not rendered by default.
Collapse file

‎scripts/release-action/calculate.go‎

Copy file name to clipboardExpand all lines: scripts/release-action/calculate.go
+40-29Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ var branchRe = regexp.MustCompile(`^release/(\d+)\.(\d+)$`)
5151
// ref is the branch name from the "Use workflow from" dropdown
5252
// (github.ref_name). commitSHA is an optional override; when empty
5353
// the tool defaults to HEAD of the ref.
54-
func calculateNextVersion(releaseType, ref, commitSHA string) (calculateResult, error) {
55-
// Ensure we have up-to-date remote state.
56-
if _, err := gitOutput("fetch", "--tags", "--force", "origin"); err != nil {
54+
func calculateNextVersion(exec CommandExecutor, releaseType, ref, commitSHA string) (calculateResult, error) {
55+
// Ensure we have up-to-date remote state. Fetching only updates
56+
// local remote-tracking refs, so it runs even in dry-run mode to
57+
// keep version calculation accurate.
58+
if _, err := gitOutput(exec, "fetch", "--tags", "--force", "origin"); err != nil {
5759
return nil, xerrors.Errorf("git fetch: %w", err)
5860
}
5961

@@ -66,21 +68,21 @@ func calculateNextVersion(releaseType, ref, commitSHA string) (calculateResult,
6668
return nil, xerrors.Errorf("rc must be run from main or a release/X.Y branch, got %q", ref)
6769
}
6870
if isMain {
69-
return calculateRCFromMainReleaseRequest(ref, commitSHA)
71+
return calculateRCFromMainReleaseRequest(exec, ref, commitSHA)
7072
}
71-
return calculateRCFromBranchReleaseRequest(ref, commitSHA)
73+
return calculateRCFromBranchReleaseRequest(exec, ref, commitSHA)
7274

7375
case "release":
7476
if !isReleaseBranch {
7577
return nil, xerrors.Errorf("release must be run from a release/X.Y branch, got %q", ref)
7678
}
77-
return createRegularReleaseRequest(ref)
79+
return createRegularReleaseRequest(exec, ref)
7880

7981
case "create-release-branch":
8082
if !isMain {
8183
return nil, xerrors.Errorf("create-release-branch must be run from main, got %q", ref)
8284
}
83-
return calculateCreateBranchRequest(ref, commitSHA)
85+
return calculateCreateBranchRequest(exec, ref, commitSHA)
8486

8587
default:
8688
return nil, xerrors.Errorf("unknown release type %q (expected rc, release, or create-release-branch)", releaseType)
@@ -90,33 +92,42 @@ func calculateNextVersion(releaseType, ref, commitSHA string) (calculateResult,
9092
// resolveCommit returns the commit SHA to tag. If commitSHA is
9193
// provided it is validated and returned; otherwise HEAD of the
9294
// ref is used.
93-
func resolveCommit(ref, commitSHA string) (string, error) {
95+
func resolveCommit(exec CommandExecutor, ref, commitSHA string) (string, error) {
9496
if commitSHA != "" {
9597
if !isHexSHA(commitSHA) {
9698
return "", xerrors.Errorf("invalid commit SHA %q: must be a hex string", commitSHA)
9799
}
98-
return commitSHA, nil
100+
// Resolve to a full commit SHA. The idempotency checks in
101+
// createAndPushTag/createAndPushBranch compare targetRef
102+
// against full SHAs (git rev-parse of an existing tag,
103+
// ls-remote branch output), so a short SHA passed via the
104+
// commit input would never match and would break re-runs.
105+
sha, err := gitOutput(exec, "rev-parse", "--verify", commitSHA+"^{commit}")
106+
if err != nil {
107+
return "", xerrors.Errorf("resolve commit %s: %w", commitSHA, err)
108+
}
109+
return sha, nil
99110
}
100-
sha, err := gitOutput("rev-parse", fmt.Sprintf("origin/%s", ref))
111+
sha, err := gitOutput(exec, "rev-parse", fmt.Sprintf("origin/%s", ref))
101112
if err != nil {
102113
return "", xerrors.Errorf("resolve HEAD of %s: %w", ref, err)
103114
}
104115
return sha, nil
105116
}
106117

107118
// calculateRCFromMainReleaseRequest tags an RC from a commit on main.
108-
func calculateRCFromMainReleaseRequest(ref, commitSHA string) (ReleaseRequest, error) {
109-
targetRef, err := resolveCommit(ref, commitSHA)
119+
func calculateRCFromMainReleaseRequest(exec CommandExecutor, ref, commitSHA string) (ReleaseRequest, error) {
120+
targetRef, err := resolveCommit(exec, ref, commitSHA)
110121
if err != nil {
111122
return ReleaseRequest{}, err
112123
}
113124

114125
// Verify commit is an ancestor of origin/main.
115-
if err := gitRun("merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil {
126+
if err := gitRun(exec, "merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil {
116127
return ReleaseRequest{}, xerrors.Errorf("commit %s is not an ancestor of origin/main", targetRef)
117128
}
118129

119-
allTags, err := listSemverTags()
130+
allTags, err := listSemverTags(exec)
120131
if err != nil {
121132
return ReleaseRequest{}, err
122133
}
@@ -158,7 +169,7 @@ func calculateRCFromMainReleaseRequest(ref, commitSHA string) (ReleaseRequest, e
158169
}
159170

160171
// calculateRCFromBranchReleaseRequest tags an RC from the tip of a release branch.
161-
func calculateRCFromBranchReleaseRequest(ref, commitSHA string) (ReleaseRequest, error) {
172+
func calculateRCFromBranchReleaseRequest(exec CommandExecutor, ref, commitSHA string) (ReleaseRequest, error) {
162173
m := branchRe.FindStringSubmatch(ref)
163174
if m == nil {
164175
return ReleaseRequest{}, xerrors.Errorf("ref %q does not match release/X.Y", ref)
@@ -167,17 +178,17 @@ func calculateRCFromBranchReleaseRequest(ref, commitSHA string) (ReleaseRequest,
167178
major, _ := strconv.Atoi(m[1])
168179
minor, _ := strconv.Atoi(m[2])
169180

170-
targetRef, err := resolveCommit(ref, commitSHA)
181+
targetRef, err := resolveCommit(exec, ref, commitSHA)
171182
if err != nil {
172183
return ReleaseRequest{}, err
173184
}
174185

175186
// Fail if there are open PRs targeting this release branch.
176-
if err := checkOpenPRs(ref); err != nil {
187+
if err := checkOpenPRs(exec, ref); err != nil {
177188
return ReleaseRequest{}, err
178189
}
179190

180-
allTags, err := listSemverTags()
191+
allTags, err := listSemverTags(exec)
181192
if err != nil {
182193
return ReleaseRequest{}, err
183194
}
@@ -215,7 +226,7 @@ func calculateRCFromBranchReleaseRequest(ref, commitSHA string) (ReleaseRequest,
215226

216227
// createRegularReleaseRequest calculates the next release (non-RC) version from
217228
// a release branch. Uses HEAD of the branch.
218-
func createRegularReleaseRequest(ref string) (ReleaseRequest, error) {
229+
func createRegularReleaseRequest(exec CommandExecutor, ref string) (ReleaseRequest, error) {
219230
m := branchRe.FindStringSubmatch(ref)
220231
if m == nil {
221232
return ReleaseRequest{}, xerrors.Errorf("ref %q does not match release/X.Y", ref)
@@ -225,17 +236,17 @@ func createRegularReleaseRequest(ref string) (ReleaseRequest, error) {
225236
minor, _ := strconv.Atoi(m[2])
226237

227238
// Resolve branch HEAD.
228-
headSHA, err := gitOutput("rev-parse", fmt.Sprintf("origin/%s", ref))
239+
headSHA, err := gitOutput(exec, "rev-parse", fmt.Sprintf("origin/%s", ref))
229240
if err != nil {
230241
return ReleaseRequest{}, xerrors.Errorf("resolve branch %s: %w", ref, err)
231242
}
232243

233244
// Fail if there are open PRs targeting this release branch.
234-
if err := checkOpenPRs(ref); err != nil {
245+
if err := checkOpenPRs(exec, ref); err != nil {
235246
return ReleaseRequest{}, err
236247
}
237248

238-
allTags, err := listSemverTags()
249+
allTags, err := listSemverTags(exec)
239250
if err != nil {
240251
return ReleaseRequest{}, err
241252
}
@@ -264,18 +275,18 @@ func createRegularReleaseRequest(ref string) (ReleaseRequest, error) {
264275

265276
// calculateCreateBranchRequest creates a release branch and tags the next
266277
// RC in one atomic step. Must be run from main.
267-
func calculateCreateBranchRequest(ref, commitSHA string) (CreateBranchRequest, error) {
268-
targetRef, err := resolveCommit(ref, commitSHA)
278+
func calculateCreateBranchRequest(exec CommandExecutor, ref, commitSHA string) (CreateBranchRequest, error) {
279+
targetRef, err := resolveCommit(exec, ref, commitSHA)
269280
if err != nil {
270281
return CreateBranchRequest{}, err
271282
}
272283

273284
// Verify commit is an ancestor of origin/main.
274-
if err := gitRun("merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil {
285+
if err := gitRun(exec, "merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil {
275286
return CreateBranchRequest{}, xerrors.Errorf("commit %s is not an ancestor of origin/main", targetRef)
276287
}
277288

278-
allTags, err := listSemverTags()
289+
allTags, err := listSemverTags(exec)
279290
if err != nil {
280291
return CreateBranchRequest{}, err
281292
}
@@ -291,7 +302,7 @@ func calculateCreateBranchRequest(ref, commitSHA string) (CreateBranchRequest, e
291302
branchName := fmt.Sprintf("release/%d.%d", nextMajor, nextMinor)
292303

293304
// Check that the branch doesn't already exist.
294-
if _, err := gitOutput("rev-parse", "--verify", fmt.Sprintf("origin/%s", branchName)); err == nil {
305+
if _, err := gitOutput(exec, "rev-parse", "--verify", fmt.Sprintf("origin/%s", branchName)); err == nil {
295306
return CreateBranchRequest{}, xerrors.Errorf("branch %s already exists", branchName)
296307
}
297308

@@ -417,8 +428,8 @@ func versionIsLess(a, b version) bool {
417428
}
418429

419430
// listSemverTags returns all semver tags from the repo.
420-
func listSemverTags() ([]version, error) {
421-
out, err := gitOutput("tag", "--list", "v*")
431+
func listSemverTags(exec CommandExecutor) ([]version, error) {
432+
out, err := gitOutput(exec, "tag", "--list", "v*")
422433
if err != nil {
423434
return nil, xerrors.Errorf("list tags: %w", err)
424435
}
Collapse file

‎scripts/release-action/calculate_test.go‎

Copy file name to clipboardExpand all lines: scripts/release-action/calculate_test.go
+50Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,53 @@ func Test_isHexSHA(t *testing.T) {
425425
})
426426
}
427427
}
428+
429+
func Test_resolveCommit(t *testing.T) {
430+
t.Parallel()
431+
432+
t.Run("ShortSHAResolvedToFull", func(t *testing.T) {
433+
t.Parallel()
434+
const full = "1234567890abcdef1234567890abcdef12345678"
435+
var gotArgs []string
436+
mock := &mockExecutor{
437+
RunOutputFunc: func(_ string, args ...string) (string, error) {
438+
gotArgs = args
439+
return full, nil
440+
},
441+
}
442+
got, err := resolveCommit(mock, "main", "1234567")
443+
require.NoError(t, err)
444+
require.Equal(t, full, got)
445+
require.Equal(t, []string{"rev-parse", "--verify", "1234567^{commit}"}, gotArgs)
446+
})
447+
448+
t.Run("EmptyResolvesRefHead", func(t *testing.T) {
449+
t.Parallel()
450+
const full = "abcdef1234567890abcdef1234567890abcdef12"
451+
var gotArgs []string
452+
mock := &mockExecutor{
453+
RunOutputFunc: func(_ string, args ...string) (string, error) {
454+
gotArgs = args
455+
return full, nil
456+
},
457+
}
458+
got, err := resolveCommit(mock, "main", "")
459+
require.NoError(t, err)
460+
require.Equal(t, full, got)
461+
require.Equal(t, []string{"rev-parse", "origin/main"}, gotArgs)
462+
})
463+
464+
t.Run("InvalidSHANotResolved", func(t *testing.T) {
465+
t.Parallel()
466+
called := false
467+
mock := &mockExecutor{
468+
RunOutputFunc: func(_ string, _ ...string) (string, error) {
469+
called = true
470+
return "", nil
471+
},
472+
}
473+
_, err := resolveCommit(mock, "main", "zzzzzzz")
474+
require.Error(t, err)
475+
require.False(t, called, "git should not be invoked for an invalid SHA")
476+
})
477+
}
Collapse file

‎scripts/release-action/cmdexec.go‎

Copy file name to clipboard
+114Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io"
7+
"os/exec"
8+
"strings"
9+
)
10+
11+
// CommandExecutor abstracts running CLI commands so that a dry-run
12+
// implementation can print commands instead of executing them.
13+
//
14+
// Read-only methods (RunOutput, Run) execute unconditionally in both
15+
// real and dry-run modes. Mutating methods (RunMutation,
16+
// RunMutationStdout) are printed instead of executed in dry-run mode.
17+
// Callers must choose the correct method at the call site so that
18+
// dry-run behavior is explicit and reviewable.
19+
type CommandExecutor interface {
20+
// RunOutput executes the named program with args and returns
21+
// trimmed stdout. Use for read-only commands.
22+
RunOutput(name string, args ...string) (string, error)
23+
24+
// Run executes the named program with args, discarding output.
25+
// Use for read-only commands where only the exit code matters.
26+
Run(name string, args ...string) error
27+
28+
// RunMutation executes a command that modifies remote state
29+
// (e.g. git push, git tag). In dry-run mode it prints instead
30+
// of executing.
31+
RunMutation(name string, args ...string) error
32+
33+
// RunMutationStdout executes a mutating command, streaming
34+
// stdout and stderr to the provided writers. Stdin is set to
35+
// empty to prevent interactive prompts. In dry-run mode it
36+
// prints instead of executing.
37+
RunMutationStdout(stdout, stderr io.Writer, name string, args ...string) error
38+
}
39+
40+
// realExecutor runs all commands for real via os/exec.
41+
type realExecutor struct{}
42+
43+
func (realExecutor) RunOutput(name string, args ...string) (string, error) {
44+
cmd := exec.Command(name, args...)
45+
out, err := cmd.Output()
46+
if err != nil {
47+
var exitErr *exec.ExitError
48+
if errors.As(err, &exitErr) {
49+
return "", exitErr
50+
}
51+
return "", err
52+
}
53+
return strings.TrimSpace(string(out)), nil
54+
}
55+
56+
func (realExecutor) Run(name string, args ...string) error {
57+
cmd := exec.Command(name, args...)
58+
return cmd.Run()
59+
}
60+
61+
func (realExecutor) RunMutation(name string, args ...string) error {
62+
cmd := exec.Command(name, args...)
63+
return cmd.Run()
64+
}
65+
66+
func (realExecutor) RunMutationStdout(stdout, stderr io.Writer, name string, args ...string) error {
67+
cmd := exec.Command(name, args...)
68+
cmd.Stdout = stdout
69+
cmd.Stderr = stderr
70+
cmd.Stdin = strings.NewReader("") // prevent interactive prompts
71+
return cmd.Run()
72+
}
73+
74+
// dryRunExecutor delegates read-only commands to the real executor
75+
// and prints mutating commands instead of executing them.
76+
type dryRunExecutor struct {
77+
w io.Writer
78+
real realExecutor
79+
}
80+
81+
func newDryRunExecutor(w io.Writer) *dryRunExecutor {
82+
return &dryRunExecutor{w: w}
83+
}
84+
85+
func (d *dryRunExecutor) RunOutput(name string, args ...string) (string, error) {
86+
return d.real.RunOutput(name, args...)
87+
}
88+
89+
func (d *dryRunExecutor) Run(name string, args ...string) error {
90+
return d.real.Run(name, args...)
91+
}
92+
93+
func (d *dryRunExecutor) RunMutation(name string, args ...string) error {
94+
_, _ = fmt.Fprintf(d.w, "[dry-run] would run: %s %s\n", name, shelljoin(args))
95+
return nil
96+
}
97+
98+
func (d *dryRunExecutor) RunMutationStdout(_, _ io.Writer, name string, args ...string) error {
99+
_, _ = fmt.Fprintf(d.w, "[dry-run] would run: %s %s\n", name, shelljoin(args))
100+
return nil
101+
}
102+
103+
// shelljoin produces a shell-safe representation of args for display.
104+
func shelljoin(args []string) string {
105+
quoted := make([]string, len(args))
106+
for i, a := range args {
107+
if strings.ContainsAny(a, " \t\n\"'\\$") {
108+
quoted[i] = "'" + strings.ReplaceAll(a, "'", "'\"'\"'") + "'"
109+
continue
110+
}
111+
quoted[i] = a
112+
}
113+
return strings.Join(quoted, " ")
114+
}

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.