Skip to content

[scripts] Introduce a script that will create release/prerelease #6954

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ worker*.log
/cadence-bench
/cadence-cassandra-tool
/cadence-sql-tool
/cadence-releaser

# SQLite databases
cadence.db*
cadence_visibility.db*
cadence_visibility.db*
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,12 @@ cadence-bench: $(BINS_DEPEND_ON)
$Q echo "compiling cadence-bench with OS: $(GOOS), ARCH: $(GOARCH)"
$Q ./scripts/build-with-ldflags.sh -o $@ cmd/bench/main.go


BINS += cadence-releaser
cadence-releaser: $(BINS_DEPEND_ON)
$Q echo "compiling cadence-releaser with OS: $(GOOS), ARCH: $(GOARCH)"
$Q ./scripts/build-with-ldflags.sh -o $@ cmd/tools/releaser/releaser.go

.PHONY: go-generate bins tools release clean

bins: $(BINS) ## Build all binaries, and any fast codegen needed (does not refresh wrappers or mocks)
Expand Down
90 changes: 90 additions & 0 deletions cmd/tools/releaser/internal/console/console.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package console

import (
"bufio"
"context"
"fmt"
"io"
"strings"
)

// Manager handles console interactions
type Manager struct {
reader io.Reader
writer io.Writer
}

// NewManager creates a new console manager
func NewManager(reader io.Reader, writer io.Writer) *Manager {
return &Manager{
reader: reader,
writer: writer,
}
}

// Confirm asks for user confirmation and returns true for 'y', false for 'n'
func (m *Manager) Confirm(ctx context.Context, message string) (bool, error) {
return m.ConfirmWithDefault(ctx, message, false)
}

// ConfirmWithDefault asks for user confirmation with a default value
// Returns defaultValue if user just presses enter
func (m *Manager) ConfirmWithDefault(ctx context.Context, message string, defaultValue bool) (bool, error) {
prompt := fmt.Sprintf("%s [y/N]: ", message)
if defaultValue {
prompt = fmt.Sprintf("%s [Y/n]: ", message)
}

_, _ = fmt.Fprint(m.writer, prompt)

inputChan := make(chan inputResult, 1)

// Start goroutine to read input
go func() {
scanner := bufio.NewScanner(m.reader)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
inputChan <- inputResult{err: fmt.Errorf("failed to read input: %w", err)}
return
}
// EOF or no input - use default
inputChan <- inputResult{text: "", err: nil}
return
}
inputChan <- inputResult{text: scanner.Text(), err: nil}
}()

var result inputResult
// Wait for either input or context cancellation
select {
case <-ctx.Done():
// Context was cancelled
return false, ctx.Err()
case result = <-inputChan:
}
// Got input from user
if result.err != nil {
return false, result.err
}

input := strings.TrimSpace(strings.ToLower(result.text))

// Empty input uses default
if input == "" {
return defaultValue, nil
}

switch input {
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
default:
return false, fmt.Errorf("invalid input: %s", input)
}
}

type inputResult struct {
text string
err error
}
200 changes: 200 additions & 0 deletions cmd/tools/releaser/internal/console/console_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package console

import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestConfirm(t *testing.T) {
tests := []struct {
name string
input string
expected bool
hasError bool
}{
{"yes response", "y\n", true, false},
{"Yes response", "Y\n", true, false},
{"yes full", "yes\n", true, false},
{"no response", "n\n", false, false},
{"No response", "N\n", false, false},
{"no full", "no\n", false, false},
{"empty input uses default false", "\n", false, false},
{"invalid input", "maybe\n", false, true},
{"whitespace input", " \n", false, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := strings.NewReader(tt.input)
writer := &bytes.Buffer{}
manager := NewManager(reader, writer)

ctx := context.Background()
result, err := manager.Confirm(ctx, "Test message")

if tt.hasError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}

// Check that prompt was written
output := writer.String()
assert.Contains(t, output, "Test message [y/N]:")
})
}
}

func TestConfirmWithDefault(t *testing.T) {
tests := []struct {
name string
input string
defaultValue bool
expected bool
hasError bool
}{
{"yes with default false", "y\n", false, true, false},
{"no with default true", "n\n", true, false, false},
{"empty uses default true", "\n", true, true, false},
{"empty uses default false", "\n", false, false, false},
{"invalid input", "invalid\n", true, false, true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := strings.NewReader(tt.input)
writer := &bytes.Buffer{}
manager := NewManager(reader, writer)

ctx := context.Background()
result, err := manager.ConfirmWithDefault(ctx, "Test message", tt.defaultValue)

if tt.hasError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}

// Check prompt format based on default
output := writer.String()
if tt.defaultValue {
assert.Contains(t, output, "[Y/n]:")
} else {
assert.Contains(t, output, "[y/N]:")
}
})
}
}

// blockingReader blocks on Read until unblocked
type blockingReader struct {
unblock chan struct{}
data []byte
read bool
}

func newBlockingReader() *blockingReader {
return &blockingReader{
unblock: make(chan struct{}),
data: []byte("y\n"),
}
}

func (br *blockingReader) Read(p []byte) (n int, err error) {
if br.read {
return 0, io.EOF
}

// Block until unblocked
<-br.unblock

br.read = true
n = copy(p, br.data)
return n, nil
}

func (br *blockingReader) Unblock() {
close(br.unblock)
}

func TestContextCancellation(t *testing.T) {
t.Run("context cancelled before input", func(t *testing.T) {
// Create a context that's already cancelled
ctx, cancel := context.WithCancel(context.Background())
cancel()

reader := strings.NewReader("y\n") // This won't be read due to cancellation
writer := &bytes.Buffer{}
manager := NewManager(reader, writer)

result, err := manager.ConfirmWithDefault(ctx, "Test", false)

require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
assert.False(t, result)
})

t.Run("context cancelled during input wait", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())

// Use a blocking reader that actually blocks
reader := newBlockingReader()
writer := &bytes.Buffer{}
manager := NewManager(reader, writer)

// Cancel context after a short delay
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()

result, err := manager.ConfirmWithDefault(ctx, "Test", false)

require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
assert.False(t, result)
})
}

// errorReader always returns an error when scanning
type errorReader struct{}

func (e errorReader) Read(p []byte) (n int, err error) {
return 0, errors.New("read error")
}

func TestScannerError(t *testing.T) {
reader := errorReader{}
writer := &bytes.Buffer{}
manager := NewManager(reader, writer)

ctx := context.Background()
result, err := manager.ConfirmWithDefault(ctx, "Test", false)

require.Error(t, err)
assert.Contains(t, err.Error(), "failed to read input")
assert.False(t, result)
}

func TestEOFHandling(t *testing.T) {
// Reader with no content (immediate EOF)
reader := strings.NewReader("")
writer := &bytes.Buffer{}
manager := NewManager(reader, writer)

ctx := context.Background()
result, err := manager.ConfirmWithDefault(ctx, "Test", true)

assert.NoError(t, err)
assert.True(t, result, "Expected default value (true) on EOF")
}
68 changes: 68 additions & 0 deletions cmd/tools/releaser/internal/fs/fs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package fs

import (
"context"
"fmt"
"os"
"path/filepath"

"golang.org/x/mod/modfile"
)

// Client implements Interface
type Client struct {
verbose bool
}

func NewFileSystemClient(verbose bool) *Client {
return &Client{verbose: verbose}
}

// FindGoModFiles reads go.work file and returns module directories
func (f *Client) FindGoModFiles(ctx context.Context, root string) ([]string, error) {
f.logDebug("Finding modules from go.work file")

workFilePath := filepath.Join(root, "go.work")
modules, err := f.parseGoWorkFile(workFilePath, root)
if err != nil {
return nil, fmt.Errorf("failed to parse go.work file: %w", err)
}

f.logDebug("Found modules from go.work: %v", modules)
return modules, nil
}

// parseGoWorkFile parses the go.work file using the official modfile package
func (f *Client) parseGoWorkFile(workFilePath, root string) ([]string, error) {
workFileData, err := os.ReadFile(workFilePath)
if err != nil {
return nil, fmt.Errorf("failed to read go.work file: %w", err)
}

workFile, err := modfile.ParseWork(workFilePath, workFileData, nil)
if err != nil {
return nil, fmt.Errorf("failed to parse go.work file: %w", err)
}

modules := make([]string, 0, len(workFile.Use))
for _, use := range workFile.Use {
modules = append(modules, use.Path)
}

return modules, nil
}

// resolveModulePath converts relative path to absolute path
func (f *Client) resolveModulePath(modulePath, root string) string {
if filepath.IsAbs(modulePath) {
return modulePath
}

return filepath.Join(root, modulePath)
}

func (f *Client) logDebug(msg string, args ...interface{}) {
if f.verbose {
fmt.Printf("%s\n", fmt.Sprintf(msg, args...))
}
}
Loading