feat(releaser): initial release v0.4.0
Complete GitFlow release automation tool for Conventional Commits workflows: - Core pipeline: branch parsing, tag discovery, commit analysis, version bump - Maven pom.xml read/write, git commit/tag, HTTPS push with token auth - GitLab release creation via API with auto-generated release notes - Configurable via .releaser.yml (tag_prefix, branch_pattern, commit_message, pom_path, gitlab) - CLI flags: --dry-run, --no-push, --no-commit, --tag-only, --branch, --pom, --tag-prefix, --branch-pattern - Dockerfile (multi-stage Alpine), .releaser.gitlab-ci.yml reusable template - Gitea CI (vet + staticcheck + test + build) and release (5-platform cross-compilation) workflows - Taskfile with build/test/cov/lint/fuzz/ci/docker tasks - 96% test coverage with real in-memory git repos and fuzz tests for all parsers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/branch"
|
||||
)
|
||||
|
||||
const filename = ".releaser.yml"
|
||||
|
||||
type Config struct {
|
||||
Git GitConfig `yaml:"git"`
|
||||
Maven MavenConfig `yaml:"maven"`
|
||||
GitLab GitLabConfig `yaml:"gitlab"`
|
||||
}
|
||||
|
||||
type GitConfig struct {
|
||||
TagPrefix string `yaml:"tag_prefix"`
|
||||
BranchPattern string `yaml:"branch_pattern"`
|
||||
CommitMessage string `yaml:"commit_message"`
|
||||
AuthorName string `yaml:"author_name"`
|
||||
AuthorEmail string `yaml:"author_email"`
|
||||
}
|
||||
|
||||
type MavenConfig struct {
|
||||
PomPath string `yaml:"pom_path"`
|
||||
}
|
||||
|
||||
type GitLabConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
Token string `yaml:"token"`
|
||||
Project string `yaml:"project"`
|
||||
}
|
||||
|
||||
func defaults() Config {
|
||||
return Config{
|
||||
Git: GitConfig{
|
||||
TagPrefix: "v",
|
||||
BranchPattern: branch.DefaultBranchPattern,
|
||||
CommitMessage: "chore(release): {version} [skip ci]",
|
||||
},
|
||||
Maven: MavenConfig{
|
||||
PomPath: "pom.xml",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads .releaser.yml from dir and merges it over the defaults.
|
||||
// Missing file is not an error — defaults are returned as-is.
|
||||
func Load(dir string) (Config, error) {
|
||||
cfg := defaults()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, filename))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return cfg, nil
|
||||
}
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("read %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("parse %s: %w", filename, err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ApplyEnv fills empty GitLab fields from the standard GitLab CI environment variables.
|
||||
// Values already set in the config file are never overwritten.
|
||||
func (c *Config) ApplyEnv() {
|
||||
if c.GitLab.Token == "" {
|
||||
c.GitLab.Token = os.Getenv("GITLAB_TOKEN")
|
||||
}
|
||||
if c.GitLab.URL == "" {
|
||||
// CI_SERVER_URL is the cleanest source ("https://gitlab.example.com")
|
||||
c.GitLab.URL = os.Getenv("CI_SERVER_URL")
|
||||
}
|
||||
if c.GitLab.Project == "" {
|
||||
// Prefer numeric ID; fall back to namespace/project path
|
||||
if id := os.Getenv("CI_PROJECT_ID"); id != "" {
|
||||
c.GitLab.Project = id
|
||||
} else {
|
||||
c.GitLab.Project = os.Getenv("CI_PROJECT_PATH")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
cfg, err := Load(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Git.TagPrefix != "v" {
|
||||
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "v")
|
||||
}
|
||||
if cfg.Maven.PomPath != "pom.xml" {
|
||||
t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := `
|
||||
git:
|
||||
tag_prefix: ""
|
||||
commit_message: "release: {version}"
|
||||
maven:
|
||||
pom_path: "services/api/pom.xml"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Git.TagPrefix != "" {
|
||||
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
|
||||
}
|
||||
if cfg.Git.CommitMessage != "release: {version}" {
|
||||
t.Errorf("CommitMessage = %q", cfg.Git.CommitMessage)
|
||||
}
|
||||
if cfg.Maven.PomPath != "services/api/pom.xml" {
|
||||
t.Errorf("PomPath = %q", cfg.Maven.PomPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a directory named .releaser.yml so os.ReadFile fails (is a directory)
|
||||
if err := os.Mkdir(filepath.Join(dir, filename), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := Load(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error when .releaser.yml is a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), []byte("{[invalid yaml"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := Load(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnv(t *testing.T) {
|
||||
t.Setenv("GITLAB_TOKEN", "mytoken")
|
||||
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
|
||||
t.Setenv("CI_PROJECT_ID", "42")
|
||||
|
||||
cfg := defaults()
|
||||
cfg.ApplyEnv()
|
||||
|
||||
if cfg.GitLab.Token != "mytoken" {
|
||||
t.Errorf("Token = %q, want mytoken", cfg.GitLab.Token)
|
||||
}
|
||||
if cfg.GitLab.URL != "https://gitlab.example.com" {
|
||||
t.Errorf("URL = %q", cfg.GitLab.URL)
|
||||
}
|
||||
if cfg.GitLab.Project != "42" {
|
||||
t.Errorf("Project = %q, want 42", cfg.GitLab.Project)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvDoesNotOverwrite(t *testing.T) {
|
||||
t.Setenv("GITLAB_TOKEN", "env-token")
|
||||
t.Setenv("CI_SERVER_URL", "https://env.example.com")
|
||||
t.Setenv("CI_PROJECT_ID", "99")
|
||||
|
||||
cfg := defaults()
|
||||
cfg.GitLab.Token = "config-token"
|
||||
cfg.GitLab.URL = "https://config.example.com"
|
||||
cfg.GitLab.Project = "config-project"
|
||||
cfg.ApplyEnv()
|
||||
|
||||
if cfg.GitLab.Token != "config-token" {
|
||||
t.Errorf("Token overwritten: got %q", cfg.GitLab.Token)
|
||||
}
|
||||
if cfg.GitLab.URL != "https://config.example.com" {
|
||||
t.Errorf("URL overwritten: got %q", cfg.GitLab.URL)
|
||||
}
|
||||
if cfg.GitLab.Project != "config-project" {
|
||||
t.Errorf("Project overwritten: got %q", cfg.GitLab.Project)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvProjectPathFallback(t *testing.T) {
|
||||
t.Setenv("CI_PROJECT_ID", "")
|
||||
t.Setenv("CI_PROJECT_PATH", "mygroup/myapp")
|
||||
|
||||
cfg := defaults()
|
||||
cfg.ApplyEnv()
|
||||
|
||||
if cfg.GitLab.Project != "mygroup/myapp" {
|
||||
t.Errorf("Project = %q, want mygroup/myapp (from CI_PROJECT_PATH)", cfg.GitLab.Project)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPartialOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Only override tag_prefix — commit_message should keep its default
|
||||
content := "git:\n tag_prefix: \"\"\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Git.TagPrefix != "" {
|
||||
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
|
||||
}
|
||||
if cfg.Git.CommitMessage != defaults().Git.CommitMessage {
|
||||
t.Errorf("CommitMessage = %q, want default", cfg.Git.CommitMessage)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user