feat(releaser): release v1.4.0 — GitHub, SSH agent, configurable bumps
ci / vet, staticcheck, test, build (push) Successful in 3m54s
release / Build and publish release (push) Successful in 4m11s

- GitHub release support: internal/ghclient (no SDK, minimal HTTP client);
  GITHUB_TOKEN env var; github.token/github.repo config; GitHub takes
  precedence over GitLab when both are configured; publisher interface
  (releasePublisher) in cmd/main.go makes providers interchangeable
- SSH agent push: gitutil.Push() now tries gitssh.NewSSHAgentAuth for
  git@/ssh:// remotes before falling back to the system git binary
- --release-env-file flag: override dotenv artifact path; pass "" to disable
- git.releasable_types config: filter which commit types trigger a bump
  (defaults to fix, feat, breaking); useful for maintenance branches
- commits.Group(), ExtractSubject(), ReleasableSet() exported helpers:
  notes.go and changelog.go now delegate to these instead of duplicating
- CHANGELOG deduplication guard: changelog.Update() is idempotent — skips
  write if ## [version] section already exists
- Always load config sources: LoadWithSources() called unconditionally;
  removes the dual config path that previously only tracked sources in
  --verbose mode
- .releaser.gitlab-ci.yml: adds artifacts: reports: dotenv: release.env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 00:15:09 +02:00
parent 62a702fb89
commit 5af107b06d
15 changed files with 428 additions and 175 deletions
+6 -31
View File
@@ -4,19 +4,17 @@ 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.
// If a section for version already exists the file is left untouched (idempotent).
func Update(path, tag, version string, messages []string) error {
section := buildSection(version, messages)
if section == "" {
@@ -31,6 +29,10 @@ func Update(path, tag, version string, messages []string) error {
return fmt.Errorf("read %s: %w", path, err)
}
if strings.Contains(existing, "## ["+version+"]") {
return nil
}
var out string
if existing == "" {
out = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" +
@@ -48,26 +50,7 @@ func Update(path, tag, version string, messages []string) error {
}
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)
}
}
breaking, feats, fixes := commits.Group(messages)
if len(breaking)+len(feats)+len(fixes) == 0 {
return ""
}
@@ -91,11 +74,3 @@ func writeSection(sb *strings.Builder, title string, items []string) {
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)
}
+56
View File
@@ -5,6 +5,62 @@ import (
"strings"
)
// headerSubjectRe captures the subject (description) from a conventional commit header.
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
// ExtractSubject returns the description part of a conventional commit header.
// Falls back to the trimmed raw header if the pattern does not match.
func ExtractSubject(header string) string {
m := headerSubjectRe.FindStringSubmatch(header)
if m != nil {
return strings.TrimSpace(m[1])
}
return strings.TrimSpace(header)
}
// Group splits messages into breaking changes, features, and fixes.
// Only the first line of each message is considered; the subject is extracted.
// Messages with TypeNone are silently dropped.
func Group(messages []string) (breaking, feats, fixes []string) {
for _, msg := range messages {
t := Parse(msg)
if t == TypeNone {
continue
}
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
subject := ExtractSubject(first)
switch t {
case TypeBreaking:
breaking = append(breaking, subject)
case TypeFeat:
feats = append(feats, subject)
case TypeFix:
fixes = append(fixes, subject)
}
}
return
}
// ReleasableSet converts a slice of type-name strings to a set for use in version.Next.
// An empty or nil slice defaults to all three releasable types (fix, feat, breaking).
func ReleasableSet(typeNames []string) map[Type]bool {
if len(typeNames) == 0 {
return map[Type]bool{TypeFix: true, TypeFeat: true, TypeBreaking: true}
}
m := make(map[Type]bool, len(typeNames))
for _, name := range typeNames {
switch strings.ToLower(name) {
case "fix":
m[TypeFix] = true
case "feat":
m[TypeFeat] = true
case "breaking":
m[TypeBreaking] = true
}
}
return m
}
// Type represents the semantic weight of a commit for versioning purposes.
type Type int
+50
View File
@@ -35,6 +35,56 @@ func FuzzParse(f *testing.F) {
})
}
func TestExtractSubject(t *testing.T) {
cases := []struct {
header string
want string
}{
{"feat: add login", "add login"},
{"feat(auth): add OAuth2", "add OAuth2"},
{"feat!: remove API", "remove API"},
{"FIX:typo", "typo"},
{"plain message", "plain message"},
}
for _, c := range cases {
got := ExtractSubject(c.header)
if got != c.want {
t.Errorf("ExtractSubject(%q) = %q, want %q", c.header, got, c.want)
}
}
}
func TestGroup(t *testing.T) {
messages := []string{
"feat: add login",
"fix: patch null pointer",
"feat!: remove legacy API",
"chore: update deps",
"fix: handle empty response",
}
breaking, feats, fixes := Group(messages)
if len(breaking) != 1 || breaking[0] != "remove legacy API" {
t.Errorf("breaking = %v, want [remove legacy API]", breaking)
}
if len(feats) != 1 || feats[0] != "add login" {
t.Errorf("feats = %v, want [add login]", feats)
}
if len(fixes) != 2 {
t.Errorf("fixes = %v, want 2 items", fixes)
}
}
func TestReleasableSet(t *testing.T) {
all := ReleasableSet(nil)
if !all[TypeFix] || !all[TypeFeat] || !all[TypeBreaking] {
t.Error("nil input should return all three types")
}
only := ReleasableSet([]string{"fix"})
if !only[TypeFix] || only[TypeFeat] || only[TypeBreaking] {
t.Errorf("fix-only set: %v", only)
}
}
func TestTypeString(t *testing.T) {
cases := []struct {
t Type
+42 -17
View File
@@ -17,14 +17,16 @@ type Config struct {
Git GitConfig `yaml:"git"`
Maven MavenConfig `yaml:"maven"`
GitLab GitLabConfig `yaml:"gitlab"`
GitHub GitHubConfig `yaml:"github"`
}
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"`
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"`
ReleasableTypes []string `yaml:"releasable_types"`
}
type MavenConfig struct {
@@ -37,6 +39,11 @@ type GitLabConfig struct {
Project string `yaml:"project"`
}
type GitHubConfig struct {
Token string `yaml:"token"`
Repo string `yaml:"repo"` // "owner/repo"
}
func defaults() Config {
return Config{
Git: GitConfig{
@@ -57,15 +64,18 @@ type Sources map[string]string
func defaultSources() Sources {
return Sources{
"git.tag_prefix": "default",
"git.branch_pattern": "default",
"git.commit_message": "default",
"git.author_name": "default",
"git.author_email": "default",
"maven.pom_path": "default",
"gitlab.url": "default",
"gitlab.token": "default",
"gitlab.project": "default",
"git.tag_prefix": "default",
"git.branch_pattern": "default",
"git.commit_message": "default",
"git.author_name": "default",
"git.author_email": "default",
"git.releasable_types": "default",
"maven.pom_path": "default",
"gitlab.url": "default",
"gitlab.token": "default",
"gitlab.project": "default",
"github.token": "default",
"github.repo": "default",
}
}
@@ -113,6 +123,9 @@ func LoadWithSources(dir string) (Config, Sources, error) {
if overlay.Git.AuthorEmail != "" {
src["git.author_email"] = "config file"
}
if len(overlay.Git.ReleasableTypes) > 0 {
src["git.releasable_types"] = "config file"
}
if overlay.Maven.PomPath != "" {
src["maven.pom_path"] = "config file"
}
@@ -125,11 +138,17 @@ func LoadWithSources(dir string) (Config, Sources, error) {
if overlay.GitLab.Project != "" {
src["gitlab.project"] = "config file"
}
if overlay.GitHub.Token != "" {
src["github.token"] = "config file"
}
if overlay.GitHub.Repo != "" {
src["github.repo"] = "config file"
}
return cfg, src, nil
}
// ApplyEnv fills empty GitLab fields from the standard GitLab CI environment variables.
// ApplyEnv fills empty GitLab and GitHub fields from environment variables.
// Values already set in the config file are never overwritten.
func (c *Config) ApplyEnv() {
c.ApplyEnvWithSources(nil)
@@ -147,7 +166,6 @@ func (c *Config) ApplyEnvWithSources(src Sources) {
}
}
if c.GitLab.URL == "" {
// CI_SERVER_URL is the cleanest source ("https://gitlab.example.com")
if v := os.Getenv("CI_SERVER_URL"); v != "" {
c.GitLab.URL = v
if src != nil {
@@ -156,7 +174,6 @@ func (c *Config) ApplyEnvWithSources(src Sources) {
}
}
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
if src != nil {
@@ -169,4 +186,12 @@ func (c *Config) ApplyEnvWithSources(src Sources) {
}
}
}
if c.GitHub.Token == "" {
if v := os.Getenv("GITHUB_TOKEN"); v != "" {
c.GitHub.Token = v
if src != nil {
src["github.token"] = "env: GITHUB_TOKEN"
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
package ghclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Client is a minimal GitHub API client covering only the Releases endpoint.
type Client struct {
token string
repo string // "owner/repo"
httpClient *http.Client
}
// New creates a Client. repo must be in "owner/repo" format.
func New(token, repo string) *Client {
return &Client{
token: token,
repo: repo,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
type createReleaseRequest struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
}
// CreateRelease creates a GitHub release on an existing tag.
// The tag must already be pushed to the remote before calling this.
func (c *Client) CreateRelease(ctx context.Context, tagName, body string) error {
payload, _ := json.Marshal(createReleaseRequest{
TagName: tagName,
Name: tagName,
Body: body,
})
url := fmt.Sprintf("https://api.github.com/repos/%s/releases", c.repo)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("GitHub API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var errBody struct {
Message string `json:"message"`
}
json.NewDecoder(resp.Body).Decode(&errBody) //nolint:errcheck
if errBody.Message != "" {
return fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, errBody.Message)
}
return fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
return nil
}
+43 -3
View File
@@ -6,14 +6,16 @@ import (
"os"
"os/exec"
"sort"
"strings"
"time"
gogit "github.com/go-git/go-git/v5"
gitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/plumbing/storer"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"git.k3nny.fr/releaser/internal/branch"
)
@@ -241,15 +243,53 @@ func CreateTag(repo *gogit.Repository, tagName string) error {
// Push pushes the given branch and tag to the "origin" remote.
// When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI.
// 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.
// When token is empty and the remote URL is SSH, go-git SSH agent auth is attempted first.
// Falls back to the system git binary so that credential helpers, netrc, and SSH keys work normally.
func Push(repo *gogit.Repository, branchName, tagName, token string) error {
if token != "" {
return pushWithGoGit(repo, branchName, tagName, token)
}
// Try SSH agent auth when the remote URL uses SSH transport.
if remote, err := repo.Remote("origin"); err == nil {
urls := remote.Config().URLs
if len(urls) > 0 && isSSHURL(urls[0]) {
if err := pushWithSSHAgent(repo, branchName, tagName); err == nil {
return nil
}
}
}
return pushWithCLI(repo, branchName, tagName)
}
func isSSHURL(u string) bool {
return strings.HasPrefix(u, "git@") || strings.HasPrefix(u, "ssh://")
}
func pushWithSSHAgent(repo *gogit.Repository, branchName, tagName string) error {
auth, err := gitssh.NewSSHAgentAuth("git")
if err != nil {
return err
}
remote, err := repo.Remote("origin")
if err != nil {
return fmt.Errorf("remote origin not found: %w", err)
}
opts := &gogit.PushOptions{
RefSpecs: []gitconfig.RefSpec{
gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)),
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
},
Auth: auth,
}
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
return fmt.Errorf("git push via SSH agent: %w", err)
}
return nil
}
func pushWithGoGit(repo *gogit.Repository, branchName, tagName, token string) error {
remote, err := repo.Remote("origin")
if err != nil {
+1 -33
View File
@@ -2,38 +2,16 @@ package notes
import (
"fmt"
"regexp"
"strings"
"git.k3nny.fr/releaser/internal/commits"
)
// headerSubjectRe captures the subject (description) part of a conventional commit header.
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
// Generate produces grouped markdown release notes from a list of commit messages.
// Commits are grouped into Breaking Changes, Features, and Bug Fixes.
// Commits with no releasable type are omitted.
func Generate(tagName 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)
}
}
breaking, feats, fixes := commits.Group(messages)
var sb strings.Builder
fmt.Fprintf(&sb, "## %s\n", tagName)
@@ -53,13 +31,3 @@ func writeSection(sb *strings.Builder, title string, items []string) {
fmt.Fprintf(sb, "- %s\n", item)
}
}
// extractSubject returns the description part of a conventional commit header.
// Falls back to the raw header if the pattern does not match.
func extractSubject(header string) string {
m := headerSubjectRe.FindStringSubmatch(header)
if m != nil {
return strings.TrimSpace(m[1])
}
return strings.TrimSpace(header)
}
-19
View File
@@ -69,25 +69,6 @@ func TestGenerateEmpty(t *testing.T) {
}
}
func TestExtractSubject(t *testing.T) {
cases := []struct {
header string
want string
}{
{"feat: add login", "add login"},
{"feat(auth): add OAuth2", "add OAuth2"},
{"feat!: remove API", "remove API"},
{"FIX:typo", "typo"},
{"plain message", "plain message"},
}
for _, c := range cases {
got := extractSubject(c.header)
if got != c.want {
t.Errorf("extractSubject(%q) = %q, want %q", c.header, got, c.want)
}
}
}
// FuzzGenerate verifies that Generate never panics on arbitrary inputs and
// always includes the tag name in the output.
func FuzzGenerate(f *testing.F) {
+6 -2
View File
@@ -8,10 +8,14 @@ import (
// Next computes the next version string (without tag prefix, e.g. "1.2.4").
// currentPatch is -1 when no tag exists yet (first release will be X.Y.0).
// releasable is the set of commit types that trigger a bump; nil defaults to all three.
// Returns ("", false) when there are no releasable commits.
func Next(major, minor, currentPatch int, types []commits.Type) (string, bool) {
func Next(major, minor, currentPatch int, types []commits.Type, releasable map[commits.Type]bool) (string, bool) {
if releasable == nil {
releasable = commits.ReleasableSet(nil)
}
for _, t := range types {
if t != commits.TypeNone {
if releasable[t] {
return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ func TestNext(t *testing.T) {
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
got, ok := Next(c.major, c.minor, c.currentPatch, c.types)
got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil)
if ok != c.wantOk {
t.Errorf("ok=%v, want %v", ok, c.wantOk)
}