Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d0489dd71 | |||
| 6ffe282105 |
@@ -3,6 +3,19 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
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
|
## [0.4.2] - 2026-07-07
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# releaser
|
# releaser
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
|
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
|
||||||
|
|
||||||
@@ -38,16 +38,22 @@ release/1.2 branch
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Scaffold a default .releaser.yml in the current repository
|
||||||
|
releaser --init
|
||||||
|
|
||||||
# Simulate next version (no side effects)
|
# Simulate next version (no side effects)
|
||||||
releaser --dry-run
|
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
|
releaser
|
||||||
|
|
||||||
# Commit and tag locally — skip push and GitLab release
|
# Commit and tag locally — skip push and GitLab release
|
||||||
releaser --no-push
|
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
|
releaser --no-commit
|
||||||
# … then commit manually and re-run:
|
# … then commit manually and re-run:
|
||||||
releaser --tag-only
|
releaser --tag-only
|
||||||
@@ -55,6 +61,9 @@ releaser --tag-only
|
|||||||
# Explicitly target a branch (useful in detached HEAD CI)
|
# Explicitly target a branch (useful in detached HEAD CI)
|
||||||
releaser --branch release/1.2
|
releaser --branch release/1.2
|
||||||
|
|
||||||
|
# Write changelog to a custom file
|
||||||
|
releaser --changelog-file CHANGES.md
|
||||||
|
|
||||||
# Target a specific pom.xml
|
# Target a specific pom.xml
|
||||||
releaser --pom path/to/pom.xml
|
releaser --pom path/to/pom.xml
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -55,10 +55,12 @@
|
|||||||
- [x] Gitea release workflow (5-platform cross-compilation, release asset upload)
|
- [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
|
- [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)
|
- [x] `CHANGELOG.md` generation / append (grouped by commit type: Breaking Changes / Added / Fixed)
|
||||||
- [ ] `--changelog-file` flag
|
- [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
|
## v1.0 — Production ready
|
||||||
|
|
||||||
|
|||||||
+87
-12
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.k3nny.fr/releaser/internal/branch"
|
"git.k3nny.fr/releaser/internal/branch"
|
||||||
|
"git.k3nny.fr/releaser/internal/changelog"
|
||||||
"git.k3nny.fr/releaser/internal/commits"
|
"git.k3nny.fr/releaser/internal/commits"
|
||||||
"git.k3nny.fr/releaser/internal/config"
|
"git.k3nny.fr/releaser/internal/config"
|
||||||
"git.k3nny.fr/releaser/internal/glclient"
|
"git.k3nny.fr/releaser/internal/glclient"
|
||||||
@@ -21,6 +22,45 @@ import (
|
|||||||
semver "git.k3nny.fr/releaser/internal/version"
|
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 (
|
var (
|
||||||
version = "dev" // overridden at build time via -ldflags "-X main.version=..."
|
version = "dev" // overridden at build time via -ldflags "-X main.version=..."
|
||||||
errNothingToRelease = errors.New("nothing to release")
|
errNothingToRelease = errors.New("nothing to release")
|
||||||
@@ -31,6 +71,7 @@ var exitFn = os.Exit
|
|||||||
|
|
||||||
func newRootCmd() *cobra.Command {
|
func newRootCmd() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
|
init_ bool
|
||||||
dryRun bool
|
dryRun bool
|
||||||
noPush bool
|
noPush bool
|
||||||
noRelease bool
|
noRelease bool
|
||||||
@@ -39,6 +80,7 @@ func newRootCmd() *cobra.Command {
|
|||||||
branchOverride string
|
branchOverride string
|
||||||
repoPath string
|
repoPath string
|
||||||
pomOverride string
|
pomOverride string
|
||||||
|
changelogFile string
|
||||||
tagPrefixFlag string
|
tagPrefixFlag string
|
||||||
tagPrefixSet bool
|
tagPrefixSet bool
|
||||||
patternFlag string
|
patternFlag string
|
||||||
@@ -54,9 +96,11 @@ func newRootCmd() *cobra.Command {
|
|||||||
tagPrefixSet = cmd.Flags().Changed("tag-prefix")
|
tagPrefixSet = cmd.Flags().Changed("tag-prefix")
|
||||||
patternSet = cmd.Flags().Changed("branch-pattern")
|
patternSet = cmd.Flags().Changed("branch-pattern")
|
||||||
return run(options{
|
return run(options{
|
||||||
|
init: init_,
|
||||||
repoPath: repoPath,
|
repoPath: repoPath,
|
||||||
branchOverride: branchOverride,
|
branchOverride: branchOverride,
|
||||||
pomOverride: pomOverride,
|
pomOverride: pomOverride,
|
||||||
|
changelogFile: changelogFile,
|
||||||
tagPrefixFlag: tagPrefixFlag,
|
tagPrefixFlag: tagPrefixFlag,
|
||||||
tagPrefixSet: tagPrefixSet,
|
tagPrefixSet: tagPrefixSet,
|
||||||
patternFlag: patternFlag,
|
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(&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(&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(&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(&noCommit, "no-commit", false, "update files 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(&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(&branchOverride, "branch", "", "override branch name detection (required in detached HEAD)")
|
||||||
root.Flags().StringVar(&repoPath, "repo", ".", "path to git repository")
|
root.Flags().StringVar(&repoPath, "repo", ".", "path to git repository")
|
||||||
root.Flags().StringVar(&pomOverride, "pom", "", "override maven.pom_path from config")
|
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(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config")
|
||||||
root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config")
|
root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config")
|
||||||
|
|
||||||
@@ -95,9 +141,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type options struct {
|
type options struct {
|
||||||
|
init bool
|
||||||
repoPath string
|
repoPath string
|
||||||
branchOverride string
|
branchOverride string
|
||||||
pomOverride string
|
pomOverride string
|
||||||
|
changelogFile string
|
||||||
tagPrefixFlag string
|
tagPrefixFlag string
|
||||||
tagPrefixSet bool
|
tagPrefixSet bool
|
||||||
patternFlag string
|
patternFlag string
|
||||||
@@ -109,6 +157,18 @@ type options struct {
|
|||||||
tagOnly bool
|
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 {
|
func run(o options) error {
|
||||||
// --- Config ---
|
// --- Config ---
|
||||||
absRepo, err := filepath.Abs(o.repoPath)
|
absRepo, err := filepath.Abs(o.repoPath)
|
||||||
@@ -116,6 +176,10 @@ func run(o options) error {
|
|||||||
return fmt.Errorf("resolve repo path: %w", err)
|
return fmt.Errorf("resolve repo path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if o.init {
|
||||||
|
return initConfig(absRepo)
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(absRepo)
|
cfg, err := config.Load(absRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -207,27 +271,41 @@ func run(o options) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- pom.xml (skipped with --tag-only or when the file does not exist) ---
|
// --- pom.xml + CHANGELOG.md (skipped with --tag-only) ---
|
||||||
|
if !o.tagOnly {
|
||||||
|
var filesToCommit []string
|
||||||
|
|
||||||
|
// pom.xml
|
||||||
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
|
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
|
||||||
_, statErr := os.Stat(pomPath)
|
_, statErr := os.Stat(pomPath)
|
||||||
hasPom := !errors.Is(statErr, os.ErrNotExist)
|
hasPom := !errors.Is(statErr, os.ErrNotExist)
|
||||||
if statErr != nil && hasPom {
|
if statErr != nil && hasPom {
|
||||||
return fmt.Errorf("check pom path: %w", statErr)
|
return fmt.Errorf("check pom path: %w", statErr)
|
||||||
}
|
}
|
||||||
|
if hasPom {
|
||||||
if !o.tagOnly && hasPom {
|
|
||||||
currentPomVersion, err := maven.ReadVersion(pomPath)
|
currentPomVersion, err := maven.ReadVersion(pomPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read pom version: %w", err)
|
return fmt.Errorf("read pom version: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "info: pom.xml: %s → %s\n", currentPomVersion, nextVersion)
|
fmt.Fprintf(os.Stderr, "info: pom.xml: %s → %s\n", currentPomVersion, nextVersion)
|
||||||
|
|
||||||
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
|
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
|
||||||
return fmt.Errorf("update pom version: %w", err)
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,14 +317,11 @@ func run(o options) error {
|
|||||||
if cfg.Git.AuthorEmail != "" {
|
if cfg.Git.AuthorEmail != "" {
|
||||||
authorEmail = cfg.Git.AuthorEmail
|
authorEmail = cfg.Git.AuthorEmail
|
||||||
}
|
}
|
||||||
|
|
||||||
commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag)
|
commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag)
|
||||||
if _, err := gitutil.CommitFile(repo, cfg.Maven.PomPath, commitMsg, authorName, authorEmail); err != nil {
|
if _, err := gitutil.CommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil {
|
||||||
return fmt.Errorf("commit pom.xml: %w", err)
|
return fmt.Errorf("commit: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "info: committed: %s\n", commitMsg)
|
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 ---
|
// --- Git tag ---
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -587,3 +588,71 @@ func TestMainError(t *testing.T) {
|
|||||||
t.Errorf("expected exit code 1 for general error, got %d", gotCode)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-11
@@ -3,6 +3,8 @@ package gitutil
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -194,15 +196,17 @@ func AuthorFromConfig(repo *gogit.Repository) (name, email string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommitFile stages filePath (relative to worktree root) and creates a commit.
|
// CommitFiles stages all filePaths (relative to worktree root) and creates a commit.
|
||||||
func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) {
|
func CommitFiles(repo *gogit.Repository, filePaths []string, message, authorName, authorEmail string) (plumbing.Hash, error) {
|
||||||
w, err := repo.Worktree()
|
w, err := repo.Worktree()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return plumbing.ZeroHash, err
|
return plumbing.ZeroHash, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := w.Add(filePath); err != nil {
|
for _, p := range filePaths {
|
||||||
return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", filePath, err)
|
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{
|
hash, err := w.Commit(message, &gogit.CommitOptions{
|
||||||
@@ -218,6 +222,11 @@ func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEma
|
|||||||
return hash, nil
|
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.
|
// CreateTag creates a lightweight tag on HEAD.
|
||||||
func CreateTag(repo *gogit.Repository, tagName string) error {
|
func CreateTag(repo *gogit.Repository, tagName string) error {
|
||||||
head, err := repo.Head()
|
head, err := repo.Head()
|
||||||
@@ -231,9 +240,17 @@ func CreateTag(repo *gogit.Repository, tagName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Push pushes the given branch and tag to the "origin" remote.
|
// Push pushes the given branch and tag to the "origin" remote.
|
||||||
// If token is non-empty, HTTPS basic auth (oauth2/token) is used.
|
// When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI.
|
||||||
// Passing an empty token lets go-git use the system credential helper or SSH agent.
|
// When token is empty, the system git binary is invoked so that credential helpers,
|
||||||
|
// SSH agents, and netrc are all available as they would be for a regular git push.
|
||||||
func Push(repo *gogit.Repository, branchName, tagName, token string) error {
|
func Push(repo *gogit.Repository, branchName, tagName, token string) error {
|
||||||
|
if token != "" {
|
||||||
|
return pushWithGoGit(repo, branchName, tagName, token)
|
||||||
|
}
|
||||||
|
return pushWithCLI(repo, branchName, tagName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushWithGoGit(repo *gogit.Repository, branchName, tagName, token string) error {
|
||||||
remote, err := repo.Remote("origin")
|
remote, err := repo.Remote("origin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("remote origin not found: %w", err)
|
return fmt.Errorf("remote origin not found: %w", err)
|
||||||
@@ -244,13 +261,10 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error {
|
|||||||
gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)),
|
gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)),
|
||||||
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
|
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
|
||||||
},
|
},
|
||||||
}
|
Auth: &githttp.BasicAuth{
|
||||||
|
|
||||||
if token != "" {
|
|
||||||
opts.Auth = &githttp.BasicAuth{
|
|
||||||
Username: "oauth2",
|
Username: "oauth2",
|
||||||
Password: token,
|
Password: token,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
|
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
|
||||||
@@ -259,6 +273,24 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pushWithCLI(repo *gogit.Repository, branchName, tagName string) error {
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get worktree: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "-C", wt.Filesystem.Root(), "push", "origin",
|
||||||
|
fmt.Sprintf("HEAD:refs/heads/%s", branchName),
|
||||||
|
fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName),
|
||||||
|
)
|
||||||
|
cmd.Stdout = os.Stderr // git push status goes to stderr conventionally
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git push: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// resolveTagToCommit follows tag objects until it reaches a commit.
|
// resolveTagToCommit follows tag objects until it reaches a commit.
|
||||||
// Handles both lightweight tags (ref → commit) and annotated tags (ref → tag object → … → commit).
|
// Handles both lightweight tags (ref → commit) and annotated tags (ref → tag object → … → commit).
|
||||||
func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) {
|
func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user