diff --git a/CHANGELOG.md b/CHANGELOG.md index 065b3e7..0af34d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.1.0] - 2026-07-07 + +### Added + +- **CHANGELOG.md auto-update** — every release now writes a new dated section to `CHANGELOG.md` (grouped by Breaking Changes / Added / Fixed), committed alongside `pom.xml` in the release commit; file is created if it does not exist +- **`--changelog-file` flag** — override the default `CHANGELOG.md` path (e.g. `--changelog-file CHANGES.md`) +- **`--init` flag** — scaffolds a fully-commented default `.releaser.yml` in the repository root; errors if the file already exists +- **`CommitFiles`** in `gitutil` — internal helper that stages multiple files before a single commit, used to bundle `pom.xml` + `CHANGELOG.md` in one release commit + +### Fixed + +- **Push without token** — go-git's HTTPS transport does not use the system credential store; when `GITLAB_TOKEN` is unset the push now delegates to the `git` CLI so credential helpers, SSH agents, and `netrc` all work as expected + ## [0.4.2] - 2026-07-07 ### Fixed diff --git a/README.md b/README.md index 3aa1ffc..bad6f82 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # releaser -![release](https://img.shields.io/badge/release-v0.4.2-blue.svg) +![release](https://img.shields.io/badge/release-v1.1.0-blue.svg) A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. @@ -38,16 +38,22 @@ release/1.2 branch ## Usage ```bash +# Scaffold a default .releaser.yml in the current repository +releaser --init + # Simulate next version (no side effects) releaser --dry-run -# Full release: bump pom.xml, commit, tag, push, GitLab release +# Full release: update pom.xml + CHANGELOG.md, commit, tag, push, GitLab release releaser # Commit and tag locally — skip push and GitLab release releaser --no-push -# Update pom.xml but stop before committing (review first) +# Push commit and tag but skip creating the GitLab release +releaser --no-release + +# Update files but stop before committing (review first) releaser --no-commit # … then commit manually and re-run: releaser --tag-only @@ -55,6 +61,9 @@ releaser --tag-only # Explicitly target a branch (useful in detached HEAD CI) releaser --branch release/1.2 +# Write changelog to a custom file +releaser --changelog-file CHANGES.md + # Target a specific pom.xml releaser --pom path/to/pom.xml diff --git a/ROADMAP.md b/ROADMAP.md index dd6cbe2..486d4d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,10 +55,12 @@ - [x] Gitea release workflow (5-platform cross-compilation, release asset upload) - [x] 96% test coverage with real in-memory git repos and fuzz tests for all parsers -## v0.5 — Changelog +## v0.5 — Changelog ✅ -- [ ] `CHANGELOG.md` generation / append (grouped by commit type) -- [ ] `--changelog-file` flag +- [x] `CHANGELOG.md` generation / append (grouped by commit type: Breaking Changes / Added / Fixed) +- [x] `--changelog-file` flag to use a custom filename +- [x] `--init` flag to scaffold a default `.releaser.yml` +- [x] Push falls back to system `git` CLI when no token is set (uses credential helpers, SSH, netrc) ## v1.0 — Production ready diff --git a/cmd/main.go b/cmd/main.go index a72cf7f..67d3502 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "git.k3nny.fr/releaser/internal/branch" + "git.k3nny.fr/releaser/internal/changelog" "git.k3nny.fr/releaser/internal/commits" "git.k3nny.fr/releaser/internal/config" "git.k3nny.fr/releaser/internal/glclient" @@ -21,6 +22,45 @@ import ( semver "git.k3nny.fr/releaser/internal/version" ) +const defaultConfigTemplate = `# .releaser.yml — configuration for git.k3nny.fr/releaser +# All fields are optional. Uncomment and adjust what you need. +# CLI flags always take precedence over values set here. + +git: + # Prefix prepended to every version tag. + # tag_prefix: "v" + + # Regex that identifies release branches. Must contain exactly two capture + # groups: group 1 = major version, group 2 = minor version. + # branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" + + # Template for the version-bump commit message. + # {version} is replaced with the full tag name (e.g. "v1.2.3"). + # commit_message: "chore(release): {version} [skip ci]" + + # Override the git commit author. When omitted, releaser reads user.name + # and user.email from the repository's git config. + # author_name: "" + # author_email: "" + +maven: + # Path to pom.xml, relative to the repository root. + # pom_path: "pom.xml" + +gitlab: + # GitLab instance URL. Falls back to the CI_SERVER_URL environment variable. + # url: "https://gitlab.example.com" + + # Personal or CI access token with api scope. + # Falls back to the GITLAB_TOKEN environment variable. + # Tip: never commit a real token here — use the environment variable instead. + # token: "" + + # Numeric project ID or "namespace/project" path. + # Falls back to CI_PROJECT_ID, then CI_PROJECT_PATH environment variables. + # project: "" +` + var ( version = "dev" // overridden at build time via -ldflags "-X main.version=..." errNothingToRelease = errors.New("nothing to release") @@ -31,6 +71,7 @@ var exitFn = os.Exit func newRootCmd() *cobra.Command { var ( + init_ bool dryRun bool noPush bool noRelease bool @@ -39,6 +80,7 @@ func newRootCmd() *cobra.Command { branchOverride string repoPath string pomOverride string + changelogFile string tagPrefixFlag string tagPrefixSet bool patternFlag string @@ -54,9 +96,11 @@ func newRootCmd() *cobra.Command { tagPrefixSet = cmd.Flags().Changed("tag-prefix") patternSet = cmd.Flags().Changed("branch-pattern") return run(options{ + init: init_, repoPath: repoPath, branchOverride: branchOverride, pomOverride: pomOverride, + changelogFile: changelogFile, tagPrefixFlag: tagPrefixFlag, tagPrefixSet: tagPrefixSet, patternFlag: patternFlag, @@ -70,14 +114,16 @@ func newRootCmd() *cobra.Command { }, } + root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit") root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes") root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release") root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release") - root.Flags().BoolVar(&noCommit, "no-commit", false, "update pom.xml but do not commit, tag, or push") - root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating pom.xml (assumes version was already committed)") + root.Flags().BoolVar(&noCommit, "no-commit", false, "update files but do not commit, tag, or push") + root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating files (assumes version was already committed)") root.Flags().StringVar(&branchOverride, "branch", "", "override branch name detection (required in detached HEAD)") root.Flags().StringVar(&repoPath, "repo", ".", "path to git repository") root.Flags().StringVar(&pomOverride, "pom", "", "override maven.pom_path from config") + root.Flags().StringVar(&changelogFile, "changelog-file", "CHANGELOG.md", "path to changelog file relative to repo root") root.Flags().StringVar(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config") root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config") @@ -95,9 +141,11 @@ func main() { } type options struct { + init bool repoPath string branchOverride string pomOverride string + changelogFile string tagPrefixFlag string tagPrefixSet bool patternFlag string @@ -109,6 +157,18 @@ type options struct { tagOnly bool } +func initConfig(absRepo string) error { + path := filepath.Join(absRepo, ".releaser.yml") + if _, err := os.Stat(path); err == nil { + return fmt.Errorf(".releaser.yml already exists in %s — delete it first if you want to reset", absRepo) + } + if err := os.WriteFile(path, []byte(defaultConfigTemplate), 0644); err != nil { + return fmt.Errorf("write .releaser.yml: %w", err) + } + fmt.Printf("created %s\n", path) + return nil +} + func run(o options) error { // --- Config --- absRepo, err := filepath.Abs(o.repoPath) @@ -116,6 +176,10 @@ func run(o options) error { return fmt.Errorf("resolve repo path: %w", err) } + if o.init { + return initConfig(absRepo) + } + cfg, err := config.Load(absRepo) if err != nil { return err @@ -207,27 +271,41 @@ func run(o options) error { return nil } - // --- pom.xml (skipped with --tag-only or when the file does not exist) --- - pomPath := filepath.Join(absRepo, cfg.Maven.PomPath) - _, statErr := os.Stat(pomPath) - hasPom := !errors.Is(statErr, os.ErrNotExist) - if statErr != nil && hasPom { - return fmt.Errorf("check pom path: %w", statErr) - } + // --- pom.xml + CHANGELOG.md (skipped with --tag-only) --- + if !o.tagOnly { + var filesToCommit []string - if !o.tagOnly && hasPom { - currentPomVersion, err := maven.ReadVersion(pomPath) - if err != nil { - return fmt.Errorf("read pom version: %w", err) + // pom.xml + pomPath := filepath.Join(absRepo, cfg.Maven.PomPath) + _, statErr := os.Stat(pomPath) + hasPom := !errors.Is(statErr, os.ErrNotExist) + if statErr != nil && hasPom { + return fmt.Errorf("check pom path: %w", statErr) + } + if hasPom { + currentPomVersion, err := maven.ReadVersion(pomPath) + if err != nil { + return fmt.Errorf("read pom version: %w", err) + } + fmt.Fprintf(os.Stderr, "info: pom.xml: %s → %s\n", currentPomVersion, nextVersion) + if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil { + return fmt.Errorf("update pom version: %w", err) + } + filesToCommit = append(filesToCommit, cfg.Maven.PomPath) + } else { + fmt.Fprintln(os.Stderr, "info: no pom.xml found — skipping version bump") } - fmt.Fprintf(os.Stderr, "info: pom.xml: %s → %s\n", currentPomVersion, nextVersion) - if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil { - return fmt.Errorf("update pom version: %w", err) + // CHANGELOG.md + changelogAbsPath := filepath.Join(absRepo, o.changelogFile) + if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil { + return fmt.Errorf("update changelog: %w", err) } + fmt.Fprintf(os.Stderr, "info: %s updated\n", o.changelogFile) + filesToCommit = append(filesToCommit, o.changelogFile) if o.noCommit { - fmt.Printf("pom.xml updated to %s — commit manually then re-run with --tag-only\n", nextVersion) + fmt.Printf("files updated to %s — commit manually then re-run with --tag-only\n", nextVersion) return nil } @@ -239,14 +317,11 @@ func run(o options) error { if cfg.Git.AuthorEmail != "" { authorEmail = cfg.Git.AuthorEmail } - commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag) - if _, err := gitutil.CommitFile(repo, cfg.Maven.PomPath, commitMsg, authorName, authorEmail); err != nil { - return fmt.Errorf("commit pom.xml: %w", err) + if _, err := gitutil.CommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil { + return fmt.Errorf("commit: %w", err) } fmt.Fprintf(os.Stderr, "info: committed: %s\n", commitMsg) - } else if !o.tagOnly && !hasPom { - fmt.Fprintln(os.Stderr, "info: no pom.xml found — skipping version bump commit") } // --- Git tag --- diff --git a/cmd/main_test.go b/cmd/main_test.go index 4f0708c..acb1135 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -587,3 +588,71 @@ func TestMainError(t *testing.T) { t.Errorf("expected exit code 1 for general error, got %d", gotCode) } } + +func TestRunInit(t *testing.T) { + dir := t.TempDir() + err := execCmd(t, "--init", "--repo", dir) + if err != nil { + t.Fatalf("--init: unexpected error: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, ".releaser.yml")) + if err != nil { + t.Fatal("expected .releaser.yml to be created") + } + if len(data) == 0 { + t.Error("expected non-empty .releaser.yml") + } +} + +func TestRunInitAlreadyExists(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("existing"), 0644) + err := execCmd(t, "--init", "--repo", dir) + if err == nil { + t.Fatal("expected error when .releaser.yml already exists") + } +} + +func TestRunChangelogCreated(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// feat") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("feat: add shiny feature", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "CHANGELOG.md")) + if err != nil { + t.Fatal("expected CHANGELOG.md to be created") + } + s := string(data) + if !strings.Contains(s, "## [1.2.0]") { + t.Error("expected version header in CHANGELOG") + } + if !strings.Contains(s, "add shiny feature") { + t.Error("expected feat subject in CHANGELOG") + } +} + +func TestRunChangelogFile(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir, "--changelog-file", "CHANGES.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "CHANGES.md")); err != nil { + t.Error("expected CHANGES.md to be created") + } +} diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go new file mode 100644 index 0000000..c8d0dd6 --- /dev/null +++ b/internal/changelog/changelog.go @@ -0,0 +1,101 @@ +package changelog + +import ( + "errors" + "fmt" + "os" + "regexp" + "strings" + "time" + + "git.k3nny.fr/releaser/internal/commits" +) + +var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`) + +// Update inserts a new release section into the CHANGELOG file at path. +// If the file does not exist it is created with a standard header. +// Only commits with a releasable type (fix, feat, breaking) produce bullets; +// if none are found the file is left untouched. +func Update(path, tag, version string, messages []string) error { + section := buildSection(version, messages) + if section == "" { + return nil + } + + existing := "" + data, err := os.ReadFile(path) + if err == nil { + existing = string(data) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read %s: %w", path, err) + } + + var out string + if existing == "" { + out = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" + + section + "\n" + } else { + // Insert above the first ## [ heading so newest release is always at top. + if idx := strings.Index(existing, "\n## ["); idx >= 0 { + out = existing[:idx+1] + section + "\n\n" + existing[idx+1:] + } else { + out = strings.TrimRight(existing, "\n") + "\n\n" + section + "\n" + } + } + + return os.WriteFile(path, []byte(out), 0644) +} + +func buildSection(version string, messages []string) string { + var breaking, feats, fixes []string + + for _, msg := range messages { + t := commits.Parse(msg) + if t == commits.TypeNone { + continue + } + first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0] + subject := extractSubject(first) + + switch t { + case commits.TypeBreaking: + breaking = append(breaking, subject) + case commits.TypeFeat: + feats = append(feats, subject) + case commits.TypeFix: + fixes = append(fixes, subject) + } + } + + if len(breaking)+len(feats)+len(fixes) == 0 { + return "" + } + + date := time.Now().Format("2006-01-02") + var sb strings.Builder + fmt.Fprintf(&sb, "## [%s] - %s\n", version, date) + writeSection(&sb, "Breaking Changes", breaking) + writeSection(&sb, "Added", feats) + writeSection(&sb, "Fixed", fixes) + + return strings.TrimRight(sb.String(), "\n") +} + +func writeSection(sb *strings.Builder, title string, items []string) { + if len(items) == 0 { + return + } + fmt.Fprintf(sb, "\n### %s\n", title) + for _, item := range items { + fmt.Fprintf(sb, "- %s\n", item) + } +} + +func extractSubject(header string) string { + m := headerSubjectRe.FindStringSubmatch(header) + if m != nil { + return strings.TrimSpace(m[1]) + } + return strings.TrimSpace(header) +} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go new file mode 100644 index 0000000..9773c61 --- /dev/null +++ b/internal/changelog/changelog_test.go @@ -0,0 +1,111 @@ +package changelog + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUpdateNewFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + + if err := Update(path, "v1.2.0", "1.2.0", []string{ + "feat: add widget", + "fix: off-by-one in parser", + }); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(path) + s := string(data) + if !strings.Contains(s, "## [1.2.0]") { + t.Error("expected version header") + } + if !strings.Contains(s, "### Added") { + t.Error("expected Added section") + } + if !strings.Contains(s, "### Fixed") { + t.Error("expected Fixed section") + } + if !strings.Contains(s, "add widget") { + t.Error("expected feat subject") + } + if !strings.Contains(s, "off-by-one in parser") { + t.Error("expected fix subject") + } +} + +func TestUpdateExistingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + + // Seed with an older release. + os.WriteFile(path, []byte("# Changelog\n\n## [1.1.0] - 2026-01-01\n\n### Added\n- old stuff\n"), 0644) + + if err := Update(path, "v1.2.0", "1.2.0", []string{"feat: new thing"}); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(path) + s := string(data) + + newIdx := strings.Index(s, "## [1.2.0]") + oldIdx := strings.Index(s, "## [1.1.0]") + if newIdx < 0 || oldIdx < 0 { + t.Fatal("both versions should appear in CHANGELOG") + } + if newIdx > oldIdx { + t.Error("new version should appear before old version") + } +} + +func TestUpdateNoReleasableCommits(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + + if err := Update(path, "v1.0.1", "1.0.1", []string{ + "chore: update deps", + "docs: fix typo", + }); err != nil { + t.Fatal(err) + } + + // File should NOT have been created. + if _, err := os.Stat(path); err == nil { + t.Error("file should not be created when there are no releasable commits") + } +} + +func TestUpdateBreakingSection(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + + if err := Update(path, "v2.0.0", "2.0.0", []string{ + "feat!: redesign API", + "fix(core): nil panic", + }); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(path) + s := string(data) + if !strings.Contains(s, "### Breaking Changes") { + t.Error("expected Breaking Changes section") + } + if !strings.Contains(s, "redesign API") { + t.Error("expected breaking subject") + } +} + +func TestUpdateReadError(t *testing.T) { + dir := t.TempDir() + // Create a directory where the file should be — ReadFile will error. + os.Mkdir(filepath.Join(dir, "CHANGELOG.md"), 0755) + + err := Update(filepath.Join(dir, "CHANGELOG.md"), "v1.0.0", "1.0.0", []string{"fix: something"}) + if err == nil { + t.Error("expected error when path is a directory") + } +} diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go index 50ddae0..e071ea4 100644 --- a/internal/gitutil/gitutil.go +++ b/internal/gitutil/gitutil.go @@ -196,15 +196,17 @@ func AuthorFromConfig(repo *gogit.Repository) (name, email string) { return } -// CommitFile stages filePath (relative to worktree root) and creates a commit. -func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) { +// CommitFiles stages all filePaths (relative to worktree root) and creates a commit. +func CommitFiles(repo *gogit.Repository, filePaths []string, message, authorName, authorEmail string) (plumbing.Hash, error) { w, err := repo.Worktree() if err != nil { return plumbing.ZeroHash, err } - if _, err := w.Add(filePath); err != nil { - return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", filePath, err) + for _, p := range filePaths { + if _, err := w.Add(p); err != nil { + return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", p, err) + } } hash, err := w.Commit(message, &gogit.CommitOptions{ @@ -220,6 +222,11 @@ func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEma return hash, nil } +// CommitFile stages a single file and creates a commit. +func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) { + return CommitFiles(repo, []string{filePath}, message, authorName, authorEmail) +} + // CreateTag creates a lightweight tag on HEAD. func CreateTag(repo *gogit.Repository, tagName string) error { head, err := repo.Head()