Skip to content
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.envrc
test/.npmrc
.idea/
/semantic-release
68 changes: 68 additions & 0 deletions cmd/semantic-release/draft_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package main

import (
"testing"

"github.com/go-semantic-release/semantic-release/v2/pkg/config"
"github.com/go-semantic-release/semantic-release/v2/pkg/provider"
)

func TestDraftOptionParsing(t *testing.T) {
tests := []struct {
name string
providerOpts map[string]string
expectedDraft bool
}{
{
name: "draft=true sets Draft to true",
providerOpts: map[string]string{"draft": "true"},
expectedDraft: true,
},
{
name: "draft=false sets Draft to false",
providerOpts: map[string]string{"draft": "false"},
expectedDraft: false,
},
{
name: "draft=yes sets Draft to false (strict matching)",
providerOpts: map[string]string{"draft": "yes"},
expectedDraft: false,
},
{
name: "draft=1 sets Draft to false (strict matching)",
providerOpts: map[string]string{"draft": "1"},
expectedDraft: false,
},
{
name: "missing draft option defaults to false",
providerOpts: map[string]string{},
expectedDraft: false,
},
{
name: "draft=TRUE (uppercase) sets Draft to false (case-sensitive)",
providerOpts: map[string]string{"draft": "TRUE"},
expectedDraft: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conf := &config.Config{
ProviderOpts: tt.providerOpts,
}

draft := false
if draftOpt, ok := conf.ProviderOpts["draft"]; ok && draftOpt == "true" {
draft = true
}

releaseConfig := &provider.CreateReleaseConfig{
Draft: draft,
}

if releaseConfig.Draft != tt.expectedDraft {
t.Errorf("expected Draft=%v, got Draft=%v", tt.expectedDraft, releaseConfig.Draft)
}
})
}
}
172 changes: 171 additions & 1 deletion cmd/semantic-release/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"regexp"
"strings"
"syscall"

Expand Down Expand Up @@ -78,6 +80,127 @@ func mergeConfigWithDefaults(defaults, conf map[string]string) {
}
}

func updateFilesBeforeRelease(patterns []string, version string) error {
v, err := semver.NewVersion(version)
if err != nil {
return fmt.Errorf("invalid version: %w", err)
}

replacements := map[string]string{
"{{version}}": version,
"{{major}}": fmt.Sprintf("%d", v.Major()),
"{{minor}}": fmt.Sprintf("%d", v.Minor()),
"{{patch}}": fmt.Sprintf("%d", v.Patch()),
}

for _, pattern := range patterns {
parts := strings.SplitN(pattern, ":", 3)
if len(parts) != 3 {
return fmt.Errorf("invalid pattern format: %s (expected file:regex:template)", pattern)
}

file := strings.TrimSpace(parts[0])
regexStr := strings.TrimSpace(parts[1])
template := parts[2]
if file == "" || regexStr == "" {
return fmt.Errorf("invalid pattern format: %s (expected file:regex:template)", pattern)
}

for k, v := range replacements {
template = strings.ReplaceAll(template, k, v)
}

fi, err := os.Stat(file)
if err != nil {
return fmt.Errorf("failed to stat %s: %w", file, err)
}

content, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("failed to read %s: %w", file, err)
}

re, err := regexp.Compile(regexStr)
if err != nil {
return fmt.Errorf("invalid regex in pattern %s: %w", pattern, err)
}
if !re.Match(content) {
return fmt.Errorf("pattern did not match any content in %s (regex=%q)", file, regexStr)
}

oldContent := string(content)
newContent := re.ReplaceAllString(oldContent, template)
if newContent == oldContent {
logger.Printf("no changes for %s\n", file)
continue
}

if err := os.WriteFile(file, []byte(newContent), fi.Mode()); err != nil {
return fmt.Errorf("failed to write %s: %w", file, err)
}

logger.Printf("updated %s\n", file)
}

return nil
}

func commitAndPushChanges(files []string, message string, branch string) (string, error) {
branch = strings.TrimPrefix(branch, "refs/heads/")
branch = strings.TrimPrefix(branch, "origin/")
if branch == "" {
return "", fmt.Errorf("branch is empty")
}

cmd := exec.Command("git", "diff", "--cached", "--quiet")
if err := cmd.Run(); err != nil {
if _, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("index has staged changes; refusing to commit update-before changes")
}
return "", fmt.Errorf("failed to check index state: %w", err)
}

for _, file := range files {
if strings.TrimSpace(file) == "" {
continue
}
cmd := exec.Command("git", "add", "--", file)
if output, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("git add failed: %s: %w", output, err)
}
}

cmd = exec.Command("git", "diff", "--cached", "--quiet")
if err := cmd.Run(); err == nil {
cmd = exec.Command("git", "rev-parse", "HEAD")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get SHA: %w", err)
}
return strings.TrimSpace(string(output)), nil
} else if _, ok := err.(*exec.ExitError); !ok {
return "", fmt.Errorf("failed to check staged diff: %w", err)
}

cmd = exec.Command("git", "commit", "-m", message)
if output, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("git commit failed: %s: %w", output, err)
}

cmd = exec.Command("git", "push", "origin", "HEAD:"+branch)
if output, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("git push failed: %s: %w", output, err)
}

cmd = exec.Command("git", "rev-parse", "HEAD")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get new SHA: %w", err)
}

return strings.TrimSpace(string(output)), nil
}

//gocyclo:ignore
func cliHandler(cmd *cobra.Command, _ []string) {
logger.Printf("version: %s\n", SRVERSION)
Expand Down Expand Up @@ -284,13 +407,60 @@ func cliHandler(cmd *cobra.Command, _ []string) {
exitIfError(errors.New("DRY RUN: no release was created"), 0)
}

logger.Println("creating release...")
// update files before release if specified
if len(conf.UpdateFilesBefore) > 0 {
logger.Println("updating files before release...")
if err := updateFilesBeforeRelease(conf.UpdateFilesBefore, newVer); err != nil {
exitIfError(err)
}

filesToCommit := make([]string, 0, len(conf.UpdateFilesBefore))
for _, pattern := range conf.UpdateFilesBefore {
parts := strings.SplitN(pattern, ":", 3)
if len(parts) == 3 {
filesToCommit = append(filesToCommit, strings.TrimSpace(parts[0]))
}
}

commitMsg := conf.FilesUpdaterOpts["commit-message"]
if commitMsg == "" {
commitMsg = fmt.Sprintf("chore: bump version to %s", newVer)
}
commitMsg = strings.ReplaceAll(commitMsg, "{{version}}", newVer)

logger.Println("committing and pushing changes...")
newSHA, err := commitAndPushChanges(filesToCommit, commitMsg, currentBranch)
if err != nil {
exitIfError(err)
}

currentSha = newSHA
logger.Printf("updated SHA to %s\n", currentSha)
}

draft := false
// only accept exact "true" string per spec - other values default to false
if draftOpt, ok := conf.ProviderOpts["draft"]; ok {
if draftOpt == "true" {
draft = true
} else {
logger.Printf("warning: draft option value '%s' is not 'true', defaulting to published release\n", draftOpt)
}
}

if draft {
logger.Println("creating draft release...")
} else {
logger.Println("creating published release...")
}

newRelease := &provider.CreateReleaseConfig{
Changelog: changelogRes,
NewVersion: newVer,
Prerelease: conf.Prerelease,
Branch: currentBranch,
SHA: currentSha,
Draft: draft,
}
exitIfError(prov.CreateRelease(newRelease))

Expand Down
Loading